Repository: reduxjs/redux-toolkit
Pull request #807 (2020-11-16 08:30:41)

AsyncThunk Matchers
AsyncThunk-related functionality: `isAsyncThunkAction`, `isFulfilled`, `isRejected`, `isPending`.

Does not yet include `isRejectedWithValue`.  Also, `isFulfilled`, `isRejected`, `isPending` do not yet accept async thunk parameters to narrow the resulting matcher, but `isAsyncThunkAction` does.

This is a Draft PR because I still need to add the functionality above and type tests.

Feedback is appreciated!

douglas-treadwell: @phryneas I changed the name of the proposed `asyncThunkType` to `requestStatus` to match `requestId`.  (Because `requestId` could be thought of as a corresponding `asyncThunkId`.)
codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/reduxjs/redux-toolkit/pr/807/builds/73506) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 7a3ec2d732614f2df9aabbb50191076d7067b274:

|Sandbox| Source |
|--|--|
|[Vanilla](https://codesandbox.io/s/vanilla-whbow)| Configuration |
|[Vanilla Typescript](https://codesandbox.io/s/vanilla-typescript-chsjs)| Configuration |
|[rsk-github-issues-example](https://codesandbox.io/s/rsk-github-issues-example-0f5t2)| Configuration |

douglas-treadwell: As of the latest commit, this PR includes:

AsyncThunk-related functionality: `isAsyncThunkAction`, `isFulfilled`, `isRejected`, `isPending` as HOCs.  Tests and type-tests are included, although I just noticed some bugs in my recently added tests.

I'm working on `isRejectedWithValue` and fixing the tests for the above HOCs.
douglas-treadwell: I've also added `isRejectedWithValue`.  Ready for re-review when you have time.
phryneas: I'll take a look after work. 

In general: do you think we can get this finished this weekend?
I'm happy to chime in & do everything that you feel you won't get to, just tell me.

It would just be great to still get this into v1.5 - and get v1.5 out at the end of the weekend, if possible :)
douglas-treadwell: > I'll take a look after work.
> 
> In general: do you think we can get this finished this weekend?
> I'm happy to chime in & do everything that you feel you won't get to, just tell me.
> 
> It would just be great to still get this into v1.5 - and get v1.5 out at the end of the weekend, if possible :)

After this PR, I think the only missing piece is the documentation.

I'm available for at least one day to work on this over the weekend.  That's likely enough time to attempt a fix of anything you'd like improved with this PR, and make an attempt at documentation for all the recently added functions.

However, if you know what you want the documentation to say, or if you find it easier to fix some areas of concern in the PR directly rather than communicate about it, I certainly won't be offended.  On the contrary, I wouldn't want progress to be delayed and would be happy for any assistance getting this finished over the weekend.
phryneas: This looks very good. Thank you a lot!

We'll take it from here - right now we have a chat of three super-motivated maintainers going on who want to get this out and are already splitting up responsibilities ;)

Patch:
diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index 16427695..9233567a 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -80,9 +80,12 @@ export type AsyncThunk<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConf
 export type AsyncThunkAction<Returned, ThunkArg, ThunkApiConfig extends AsyncThunkConfig> = (dispatch: GetDispatch<ThunkApiConfig>, getState: () => GetState<ThunkApiConfig>, extra: GetExtra<ThunkApiConfig>) => Promise<PayloadAction<Returned, string, {
     arg: ThunkArg;
     requestId: string;
+    requestStatus: 'fulfilled';
 }> | PayloadAction<undefined | GetRejectValue<ThunkApiConfig>, string, {
     arg: ThunkArg;
     requestId: string;
+    requestStatus: 'rejected';
+    isRejectedWithValue?: boolean;
     aborted: boolean;
     condition: boolean;
 }, SerializedError>> & {
@@ -307,12 +310,42 @@ export function isAllOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(...m
 // @public
 export function isAnyOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(...matchers: Matchers): (action: any) => action is ActionFromMatcher<Matchers[number]>;
 
+// @public
+export function isAsyncThunkAction(): (action: any) => action is UnknownAsyncThunkAction;
+
+// @public
+export function isAsyncThunkAction<AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]>(...asyncThunks: AsyncThunks): (action: any) => action is ActionsFromAsyncThunk<AsyncThunks[number]>;
+
+// @public
+export function isFulfilled(): (action: any) => action is UnknownAsyncThunkFulfilledAction;
+
+// @public
+export function isFulfilled<AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]>(...asyncThunks: AsyncThunks): (action: any) => action is FulfilledActionFromAsyncThunk<AsyncThunks[number]>;
+
 // @public
 export function isImmutableDefault(value: unknown): boolean;
 
+// @public
+export function isPending(): (action: any) => action is UnknownAsyncThunkPendingAction;
+
+// @public
+export function isPending<AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]>(...asyncThunks: AsyncThunks): (action: any) => action is PendingActionFromAsyncThunk<AsyncThunks[number]>;
+
 // @public
 export function isPlain(val: any): boolean;
 
+// @public
+export function isRejected(): (action: any) => action is UnknownAsyncThunkRejectedAction;
+
+// @public
+export function isRejected<AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]>(...asyncThunks: AsyncThunks): (action: any) => action is RejectedActionFromAsyncThunk<AsyncThunks[number]>;
+
+// @public
+export function isRejectedWithValue(): (action: any) => action is UnknownAsyncThunkRejectedAction;
+
+// @public
+export function isRejectedWithValue<AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]>(...asyncThunks: AsyncThunks): (action: any) => action is RejectedActionFromAsyncThunk<AsyncThunks[number]>;
+
 // @public (undocumented)
 export class MiddlewareArray<Middlewares extends Middleware<any, any>> extends Array<Middlewares> {
     // (undocumented)
diff --git a/src/createAsyncThunk.ts b/src/createAsyncThunk.ts
index 8650ba8f..1f56e858 100644
--- a/src/createAsyncThunk.ts
+++ b/src/createAsyncThunk.ts
@@ -148,13 +148,19 @@ export type AsyncThunkAction<
   getState: () => GetState<ThunkApiConfig>,
   extra: GetExtra<ThunkApiConfig>
 ) => Promise<
-  | PayloadAction<Returned, string, { arg: ThunkArg; requestId: string }>
+  | PayloadAction<
+      Returned,
+      string,
+      { arg: ThunkArg; requestId: string; requestStatus: 'fulfilled' }
+    >
   | PayloadAction<
       undefined | GetRejectValue<ThunkApiConfig>,
       string,
       {
         arg: ThunkArg
         requestId: string
+        requestStatus: 'rejected'
+        isRejectedWithValue?: boolean
         aborted: boolean
         condition: boolean
       },
@@ -217,7 +223,7 @@ interface AsyncThunkOptions<
   dispatchConditionRejection?: boolean
 }
 
-type AsyncThunkPendingActionCreator<
+export type AsyncThunkPendingActionCreator<
   ThunkArg
 > = ActionCreatorWithPreparedPayload<
   [string, ThunkArg],
@@ -227,10 +233,11 @@ type AsyncThunkPendingActionCreator<
   {
     arg: ThunkArg
     requestId: string
+    requestStatus: 'pending'
   }
 >
 
-type AsyncThunkRejectedActionCreator<
+export type AsyncThunkRejectedActionCreator<
   ThunkArg,
   ThunkApiConfig
 > = ActionCreatorWithPreparedPayload<
@@ -246,12 +253,14 @@ type AsyncThunkRejectedActionCreator<
   {
     arg: ThunkArg
     requestId: string
+    requestStatus: 'rejected'
+    isRejectedWithValue?: boolean
     aborted: boolean
     condition: boolean
   }
 >
 
-type AsyncThunkFulfilledActionCreator<
+export type AsyncThunkFulfilledActionCreator<
   Returned,
   ThunkArg
 > = ActionCreatorWithPreparedPayload<
@@ -262,6 +271,7 @@ type AsyncThunkFulfilledActionCreator<
   {
     arg: ThunkArg
     requestId: string
+    requestStatus: 'fulfilled'
   }
 >
 
@@ -306,7 +316,11 @@ export function createAsyncThunk<
     (result: Returned, requestId: string, arg: ThunkArg) => {
       return {
         payload: result,
-        meta: { arg, requestId }
+        meta: {
+          arg,
+          requestId,
+          requestStatus: 'fulfilled' as const
+        }
       }
     }
   )
@@ -316,7 +330,11 @@ export function createAsyncThunk<
     (requestId: string, arg: ThunkArg) => {
       return {
         payload: undefined,
-        meta: { arg, requestId }
+        meta: {
+          arg,
+          requestId,
+          requestStatus: 'pending' as const
+        }
       }
     }
   )
@@ -327,7 +345,8 @@ export function createAsyncThunk<
       error: Error | null,
       requestId: string,
       arg: ThunkArg,
-      payload?: RejectedValue
+      payload?: RejectedValue,
+      isRejectedWithValue?: boolean
     ) => {
       const aborted = !!error && error.name === 'AbortError'
       const condition = !!error && error.name === 'ConditionError'
@@ -337,6 +356,8 @@ export function createAsyncThunk<
         meta: {
           arg,
           requestId,
+          requestStatus: 'rejected' as const,
+          isRejectedWithValue: isRejectedWithValue,
           aborted,
           condition
         }
@@ -426,7 +447,7 @@ If you want to use the AbortController to react to \`abort\` events, please cons
               })
             ).then(result => {
               if (result instanceof RejectWithValue) {
-                return rejected(null, requestId, arg, result.value)
+                return rejected(null, requestId, arg, result.value, true)
               }
               return fulfilled(result, requestId, arg)
             })
diff --git a/src/index.ts b/src/index.ts
index d31b3bd3..1afc77db 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -110,6 +110,11 @@ export {
   // js
   isAllOf,
   isAnyOf,
+  isPending,
+  isRejected,
+  isFulfilled,
+  isAsyncThunkAction,
+  isRejectedWithValue,
   // types
   ActionMatchingAllOf,
   ActionMatchingAnyOf
diff --git a/src/matchers.ts b/src/matchers.ts
index 77c467c8..e541eef2 100644
--- a/src/matchers.ts
+++ b/src/matchers.ts
@@ -4,6 +4,12 @@ import {
   Matcher,
   UnionToIntersection
 } from './tsHelpers'
+import {
+  AsyncThunk,
+  AsyncThunkFulfilledActionCreator,
+  AsyncThunkPendingActionCreator,
+  AsyncThunkRejectedActionCreator
+} from './createAsyncThunk'
 
 /** @public */
 export type ActionMatchingAnyOf<
@@ -56,3 +62,288 @@ export function isAllOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(
     return matchers.every(matcher => matches(matcher, action))
   }
 }
+
+/**
+ * @param action A redux action
+ * @param validStatus An array of valid meta.requestStatus values
+ *
+ * @internal
+ */
+export function hasExpectedRequestMetadata(action: any, validStatus: string[]) {
+  if (!action || !action.meta) return false
+
+  const hasValidRequestId = typeof action.meta.requestId === 'string'
+  const hasValidRequestStatus =
+    validStatus.indexOf(action.meta.requestStatus) > -1
+
+  return hasValidRequestId && hasValidRequestStatus
+}
+
+export type UnknownAsyncThunkPendingAction = ReturnType<
+  AsyncThunkPendingActionCreator<unknown>
+>
+
+export type PendingActionFromAsyncThunk<
+  T extends AnyAsyncThunk
+> = ActionFromMatcher<T['pending']>
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action was created by an async thunk action creator, and that
+ * the action is pending.
+ *
+ * @public
+ */
+export function isPending(): (
+  action: any
+) => action is UnknownAsyncThunkPendingAction
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action belongs to one of the provided async thunk action creators,
+ * and that the action is pending.
+ *
+ * @param asyncThunks (optional) The async thunk action creators to match against.
+ *
+ * @public
+ */
+export function isPending<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(
+  ...asyncThunks: AsyncThunks
+): (action: any) => action is PendingActionFromAsyncThunk<AsyncThunks[number]>
+export function isPending<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(...asyncThunks: AsyncThunks) {
+  if (asyncThunks.length === 0) {
+    return (action: any) => hasExpectedRequestMetadata(action, ['pending'])
+  }
+
+  return (
+    action: any
+  ): action is PendingActionFromAsyncThunk<AsyncThunks[number]> => {
+    // note: this type will be correct because we have at least 1 asyncThunk
+    const matchers: [Matcher<any>, ...Matcher<any>[]] = asyncThunks.map(
+      asyncThunk => asyncThunk.pending
+    ) as any
+
+    const combinedMatcher = isAnyOf(...matchers)
+
+    return combinedMatcher(action)
+  }
+}
+
+export type UnknownAsyncThunkRejectedAction = ReturnType<
+  AsyncThunkRejectedActionCreator<unknown, unknown>
+>
+
+export type RejectedActionFromAsyncThunk<
+  T extends AnyAsyncThunk
+> = ActionFromMatcher<T['rejected']>
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action was created by an async thunk action creator, and that
+ * the action is rejected.
+ *
+ * @public
+ */
+export function isRejected(): (
+  action: any
+) => action is UnknownAsyncThunkRejectedAction
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action belongs to one of the provided async thunk action creators,
+ * and that the action is rejected.
+ *
+ * @param asyncThunks (optional) The async thunk action creators to match against.
+ *
+ * @public
+ */
+export function isRejected<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(
+  ...asyncThunks: AsyncThunks
+): (action: any) => action is RejectedActionFromAsyncThunk<AsyncThunks[number]>
+export function isRejected<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(...asyncThunks: AsyncThunks) {
+  if (asyncThunks.length === 0) {
+    return (action: any) => hasExpectedRequestMetadata(action, ['rejected'])
+  }
+
+  return (
+    action: any
+  ): action is RejectedActionFromAsyncThunk<AsyncThunks[number]> => {
+    // note: this type will be correct because we have at least 1 asyncThunk
+    const matchers: [Matcher<any>, ...Matcher<any>[]] = asyncThunks.map(
+      asyncThunk => asyncThunk.rejected
+    ) as any
+
+    const combinedMatcher = isAnyOf(...matchers)
+
+    return combinedMatcher(action)
+  }
+}
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action was created by an async thunk action creator, and that
+ * the action is rejected with value.
+ *
+ * @public
+ */
+export function isRejectedWithValue(): (
+  action: any
+) => action is UnknownAsyncThunkRejectedAction
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action belongs to one of the provided async thunk action creators,
+ * and that the action is rejected with value.
+ *
+ * @param asyncThunks (optional) The async thunk action creators to match against.
+ *
+ * @public
+ */
+export function isRejectedWithValue<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(
+  ...asyncThunks: AsyncThunks
+): (action: any) => action is RejectedActionFromAsyncThunk<AsyncThunks[number]>
+export function isRejectedWithValue<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(...asyncThunks: AsyncThunks) {
+  const hasFlag = (action: any): action is any => {
+    return action && action.meta && action.meta.isRejectedWithValue
+  }
+
+  if (asyncThunks.length === 0) {
+    return (action: any) => {
+      const combinedMatcher = isAllOf(isRejected(...asyncThunks), hasFlag)
+
+      return combinedMatcher(action)
+    }
+  }
+
+  return (
+    action: any
+  ): action is RejectedActionFromAsyncThunk<AsyncThunks[number]> => {
+    const combinedMatcher = isAllOf(isRejected(...asyncThunks), hasFlag)
+
+    return combinedMatcher(action)
+  }
+}
+
+export type UnknownAsyncThunkFulfilledAction = ReturnType<
+  AsyncThunkFulfilledActionCreator<unknown, unknown>
+>
+
+export type FulfilledActionFromAsyncThunk<
+  T extends AnyAsyncThunk
+> = ActionFromMatcher<T['fulfilled']>
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action was created by an async thunk action creator, and that
+ * the action is fulfilled.
+ *
+ * @public
+ */
+export function isFulfilled(): (
+  action: any
+) => action is UnknownAsyncThunkFulfilledAction
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action belongs to one of the provided async thunk action creators,
+ * and that the action is fulfilled.
+ *
+ * @param asyncThunks (optional) The async thunk action creators to match against.
+ *
+ * @public
+ */
+export function isFulfilled<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(
+  ...asyncThunks: AsyncThunks
+): (action: any) => action is FulfilledActionFromAsyncThunk<AsyncThunks[number]>
+export function isFulfilled<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(...asyncThunks: AsyncThunks) {
+  if (asyncThunks.length === 0) {
+    return (action: any) => hasExpectedRequestMetadata(action, ['fulfilled'])
+  }
+
+  return (
+    action: any
+  ): action is FulfilledActionFromAsyncThunk<AsyncThunks[number]> => {
+    // note: this type will be correct because we have at least 1 asyncThunk
+    const matchers: [Matcher<any>, ...Matcher<any>[]] = asyncThunks.map(
+      asyncThunk => asyncThunk.fulfilled
+    ) as any
+
+    const combinedMatcher = isAnyOf(...matchers)
+
+    return combinedMatcher(action)
+  }
+}
+
+export type UnknownAsyncThunkAction =
+  | UnknownAsyncThunkPendingAction
+  | UnknownAsyncThunkRejectedAction
+  | UnknownAsyncThunkFulfilledAction
+
+export type AnyAsyncThunk = AsyncThunk<any, any, any>
+
+export type ActionsFromAsyncThunk<T extends AnyAsyncThunk> =
+  | ActionFromMatcher<T['pending']>
+  | ActionFromMatcher<T['fulfilled']>
+  | ActionFromMatcher<T['rejected']>
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action was created by an async thunk action creator.
+ *
+ * @public
+ */
+export function isAsyncThunkAction(): (
+  action: any
+) => action is UnknownAsyncThunkAction
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action belongs to one of the provided async thunk action creators.
+ *
+ * @param asyncThunks (optional) The async thunk action creators to match against.
+ *
+ * @public
+ */
+export function isAsyncThunkAction<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(
+  ...asyncThunks: AsyncThunks
+): (action: any) => action is ActionsFromAsyncThunk<AsyncThunks[number]>
+export function isAsyncThunkAction<
+  AsyncThunks extends [AnyAsyncThunk, ...AnyAsyncThunk[]]
+>(...asyncThunks: AsyncThunks) {
+  if (asyncThunks.length === 0) {
+    return (action: any) =>
+      hasExpectedRequestMetadata(action, ['pending', 'fulfilled', 'rejected'])
+  }
+
+  return (
+    action: any
+  ): action is ActionsFromAsyncThunk<AsyncThunks[number]> => {
+    // note: this type will be correct because we have at least 1 asyncThunk
+    const matchers: [Matcher<any>, ...Matcher<any>[]] = [] as any
+
+    for (const asyncThunk of asyncThunks) {
+      matchers.push(
+        asyncThunk.pending,
+        asyncThunk.rejected,
+        asyncThunk.fulfilled
+      )
+    }
+
+    const combinedMatcher = isAnyOf(...matchers)
+
+    return combinedMatcher(action)
+  }
+}


Test patch:
diff --git a/src/createAsyncThunk.test.ts b/src/createAsyncThunk.test.ts
index 359127bd..9fdf06a7 100644
--- a/src/createAsyncThunk.test.ts
+++ b/src/createAsyncThunk.test.ts
@@ -588,7 +588,8 @@ describe('conditional skipping of asyncThunks', () => {
           aborted: false,
           arg: arg,
           condition: true,
-          requestId: expect.stringContaining('')
+          requestId: expect.stringContaining(''),
+          requestStatus: 'rejected'
         },
         payload: undefined,
         type: 'test/rejected'
diff --git a/src/matchers.test.ts b/src/matchers.test.ts
index 0136b2cf..410dc33d 100644
--- a/src/matchers.test.ts
+++ b/src/matchers.test.ts
@@ -1,4 +1,12 @@
-import { isAllOf, isAnyOf } from './matchers'
+import {
+  isAllOf,
+  isAnyOf,
+  isAsyncThunkAction,
+  isFulfilled,
+  isPending,
+  isRejected,
+  isRejectedWithValue
+} from './matchers'
 import { createAction } from './createAction'
 import { createAsyncThunk } from './createAsyncThunk'
 import { createReducer } from './createReducer'
@@ -131,3 +139,294 @@ describe('isAllOf', () => {
     ).toBe(false)
   })
 })
+
+describe('isPending', () => {
+  test('should return false for a regular action', () => {
+    const action = createAction<string>('action/type')('testPayload')
+
+    expect(isPending()(action)).toBe(false)
+  })
+
+  test('should return true only for pending async thunk actions', () => {
+    const thunk = createAsyncThunk<string>('a', () => 'result')
+
+    const pendingAction = thunk.pending('fakeRequestId')
+    expect(isPending()(pendingAction)).toBe(true)
+
+    const rejectedAction = thunk.rejected(
+      new Error('rejected'),
+      'fakeRequestId'
+    )
+    expect(isPending()(rejectedAction)).toBe(false)
+
+    const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+    expect(isPending()(fulfilledAction)).toBe(false)
+  })
+
+  test('should return true only for thunks provided as arguments', () => {
+    const thunkA = createAsyncThunk<string>('a', () => 'result')
+    const thunkB = createAsyncThunk<string>('b', () => 'result')
+    const thunkC = createAsyncThunk<string>('c', () => 'result')
+
+    const matchAC = isPending(thunkA, thunkC)
+
+    function testPendingAction(
+      thunk: typeof thunkA | typeof thunkB | typeof thunkC,
+      expected: boolean
+    ) {
+      const pendingAction = thunk.pending('fakeRequestId')
+      expect(matchAC(pendingAction)).toBe(expected)
+
+      const rejectedAction = thunk.rejected(
+        new Error('rejected'),
+        'fakeRequestId'
+      )
+      expect(matchAC(rejectedAction)).toBe(false)
+
+      const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+      expect(matchAC(fulfilledAction)).toBe(false)
+    }
+
+    testPendingAction(thunkA, true)
+    testPendingAction(thunkC, true)
+    testPendingAction(thunkB, false)
+  })
+})
+
+describe('isRejected', () => {
+  test('should return false for a regular action', () => {
+    const action = createAction<string>('action/type')('testPayload')
+
+    expect(isRejected()(action)).toBe(false)
+  })
+
+  test('should return true only for rejected async thunk actions', () => {
+    const thunk = createAsyncThunk<string>('a', () => 'result')
+
+    const pendingAction = thunk.pending('fakeRequestId')
+    expect(isRejected()(pendingAction)).toBe(false)
+
+    const rejectedAction = thunk.rejected(
+      new Error('rejected'),
+      'fakeRequestId'
+    )
+    expect(isRejected()(rejectedAction)).toBe(true)
+
+    const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+    expect(isRejected()(fulfilledAction)).toBe(false)
+  })
+
+  test('should return true only for thunks provided as arguments', () => {
+    const thunkA = createAsyncThunk<string>('a', () => 'result')
+    const thunkB = createAsyncThunk<string>('b', () => 'result')
+    const thunkC = createAsyncThunk<string>('c', () => 'result')
+
+    const matchAC = isRejected(thunkA, thunkC)
+
+    function testRejectedAction(
+      thunk: typeof thunkA | typeof thunkB | typeof thunkC,
+      expected: boolean
+    ) {
+      const pendingAction = thunk.pending('fakeRequestId')
+      expect(matchAC(pendingAction)).toBe(false)
+
+      const rejectedAction = thunk.rejected(
+        new Error('rejected'),
+        'fakeRequestId'
+      )
+      expect(matchAC(rejectedAction)).toBe(expected)
+
+      const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+      expect(matchAC(fulfilledAction)).toBe(false)
+    }
+
+    testRejectedAction(thunkA, true)
+    testRejectedAction(thunkC, true)
+    testRejectedAction(thunkB, false)
+  })
+})
+
+describe('isRejectedWithValue', () => {
+  test('should return false for a regular action', () => {
+    const action = createAction<string>('action/type')('testPayload')
+
+    expect(isRejectedWithValue()(action)).toBe(false)
+  })
+
+  test('should return true only for rejected-with-value async thunk actions', async () => {
+    const thunk = createAsyncThunk<string>('a', (_, { rejectWithValue }) => {
+      return rejectWithValue('rejectWithValue!')
+    })
+
+    const pendingAction = thunk.pending('fakeRequestId')
+    expect(isRejectedWithValue()(pendingAction)).toBe(false)
+
+    const rejectedAction = thunk.rejected(
+      new Error('rejected'),
+      'fakeRequestId'
+    )
+    expect(isRejectedWithValue()(rejectedAction)).toBe(false)
+
+    const getState = jest.fn(() => ({}))
+    const dispatch = jest.fn((x: any) => x)
+    const extra = {}
+
+    // note: doesn't throw because we don't unwrap it
+    const rejectedWithValueAction = await thunk()(dispatch, getState, extra)
+
+    expect(isRejectedWithValue()(rejectedWithValueAction)).toBe(true)
+
+    const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+    expect(isRejectedWithValue()(fulfilledAction)).toBe(false)
+  })
+
+  test('should return true only for thunks provided as arguments', async () => {
+    const payloadCreator = (_: any, { rejectWithValue }: any) => {
+      return rejectWithValue('rejectWithValue!')
+    }
+
+    const thunkA = createAsyncThunk<string>('a', payloadCreator)
+    const thunkB = createAsyncThunk<string>('b', payloadCreator)
+    const thunkC = createAsyncThunk<string>('c', payloadCreator)
+
+    const matchAC = isRejectedWithValue(thunkA, thunkC)
+
+    async function testRejectedAction(
+      thunk: typeof thunkA | typeof thunkB | typeof thunkC,
+      expected: boolean
+    ) {
+      const pendingAction = thunk.pending('fakeRequestId')
+      expect(matchAC(pendingAction)).toBe(false)
+
+      const rejectedAction = thunk.rejected(
+        new Error('rejected'),
+        'fakeRequestId'
+      )
+      // rejected-with-value is a narrower requirement than rejected
+      expect(matchAC(rejectedAction)).toBe(false)
+
+      const getState = jest.fn(() => ({}))
+      const dispatch = jest.fn((x: any) => x)
+      const extra = {}
+
+      // note: doesn't throw because we don't unwrap it
+      const rejectedWithValueAction = await thunk()(dispatch, getState, extra)
+
+      expect(matchAC(rejectedWithValueAction)).toBe(expected)
+
+      const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+      expect(matchAC(fulfilledAction)).toBe(false)
+    }
+
+    await testRejectedAction(thunkA, true)
+    await testRejectedAction(thunkC, true)
+    await testRejectedAction(thunkB, false)
+  })
+})
+
+describe('isFulfilled', () => {
+  test('should return false for a regular action', () => {
+    const action = createAction<string>('action/type')('testPayload')
+
+    expect(isFulfilled()(action)).toBe(false)
+  })
+
+  test('should return true only for fulfilled async thunk actions', () => {
+    const thunk = createAsyncThunk<string>('a', () => 'result')
+
+    const pendingAction = thunk.pending('fakeRequestId')
+    expect(isFulfilled()(pendingAction)).toBe(false)
+
+    const rejectedAction = thunk.rejected(
+      new Error('rejected'),
+      'fakeRequestId'
+    )
+    expect(isFulfilled()(rejectedAction)).toBe(false)
+
+    const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+    expect(isFulfilled()(fulfilledAction)).toBe(true)
+  })
+
+  test('should return true only for thunks provided as arguments', () => {
+    const thunkA = createAsyncThunk<string>('a', () => 'result')
+    const thunkB = createAsyncThunk<string>('b', () => 'result')
+    const thunkC = createAsyncThunk<string>('c', () => 'result')
+
+    const matchAC = isFulfilled(thunkA, thunkC)
+
+    function testFulfilledAction(
+      thunk: typeof thunkA | typeof thunkB | typeof thunkC,
+      expected: boolean
+    ) {
+      const pendingAction = thunk.pending('fakeRequestId')
+      expect(matchAC(pendingAction)).toBe(false)
+
+      const rejectedAction = thunk.rejected(
+        new Error('rejected'),
+        'fakeRequestId'
+      )
+      expect(matchAC(rejectedAction)).toBe(false)
+
+      const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+      expect(matchAC(fulfilledAction)).toBe(expected)
+    }
+
+    testFulfilledAction(thunkA, true)
+    testFulfilledAction(thunkC, true)
+    testFulfilledAction(thunkB, false)
+  })
+})
+
+describe('isAsyncThunkAction', () => {
+  test('should return false for a regular action', () => {
+    const action = createAction<string>('action/type')('testPayload')
+
+    expect(isAsyncThunkAction()(action)).toBe(false)
+  })
+
+  test('should return true for any async thunk action if no arguments were provided', () => {
+    const thunk = createAsyncThunk<string>('a', () => 'result')
+    const matcher = isAsyncThunkAction()
+
+    const pendingAction = thunk.pending('fakeRequestId')
+    expect(matcher(pendingAction)).toBe(true)
+
+    const rejectedAction = thunk.rejected(
+      new Error('rejected'),
+      'fakeRequestId'
+    )
+    expect(matcher(rejectedAction)).toBe(true)
+
+    const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+    expect(matcher(fulfilledAction)).toBe(true)
+  })
+
+  test('should return true only for thunks provided as arguments', () => {
+    const thunkA = createAsyncThunk<string>('a', () => 'result')
+    const thunkB = createAsyncThunk<string>('b', () => 'result')
+    const thunkC = createAsyncThunk<string>('c', () => 'result')
+
+    const matchAC = isAsyncThunkAction(thunkA, thunkC)
+
+    function testAllActions(
+      thunk: typeof thunkA | typeof thunkB | typeof thunkC,
+      expected: boolean
+    ) {
+      const pendingAction = thunk.pending('fakeRequestId')
+      expect(matchAC(pendingAction)).toBe(expected)
+
+      const rejectedAction = thunk.rejected(
+        new Error('rejected'),
+        'fakeRequestId'
+      )
+      expect(matchAC(rejectedAction)).toBe(expected)
+
+      const fulfilledAction = thunk.fulfilled('result', 'fakeRequestId')
+      expect(matchAC(fulfilledAction)).toBe(expected)
+    }
+
+    testAllActions(thunkA, true)
+    testAllActions(thunkC, true)
+    testAllActions(thunkB, false)
+  })
+})
diff --git a/type-tests/files/matchers.typetest.ts b/type-tests/files/matchers.typetest.ts
index a36cb760..d1fe1701 100644
--- a/type-tests/files/matchers.typetest.ts
+++ b/type-tests/files/matchers.typetest.ts
@@ -1,5 +1,15 @@
 import { AnyAction } from 'redux'
-import { createAction, createAsyncThunk, isAllOf, isAnyOf } from '../../src'
+import {
+  createAction,
+  createAsyncThunk,
+  isAllOf,
+  isAnyOf,
+  isAsyncThunkAction,
+  isFulfilled,
+  isPending,
+  isRejected,
+  isRejectedWithValue
+} from '../../src'
 
 /* isAnyOf */
 
@@ -203,3 +213,96 @@ function isAllOfTypeGuardTest(action: AnyAction) {
     };
   }
 }
+
+/*
+ * Test: isPending correctly narrows types
+ */
+function isPendingTest(action: AnyAction) {
+  const thunk = createAsyncThunk<string>('a', () => 'result')
+
+  if (isPending(thunk)(action)) {
+    return {
+      // we should expect the payload to be undefined
+      // typings:expect-error
+      prop1: action.payload.charAt(0),
+      // we should not expect an error property
+      // typings:expect-error
+      prop2: action.error,
+    };
+  }
+}
+
+/*
+ * Test: isRejected correctly narrows types
+ */
+function isRejectedTest(action: AnyAction) {
+  const thunk = createAsyncThunk<string>('a', () => 'result')
+
+  if (isRejected(thunk)(action)) {
+    return {
+      // we should expect the payload to be defined ...
+      prop1a: action.payload,
+      // ... but of unknown type
+      // typings:expect-error
+      prop1b: action.payload.charAt(0),
+      // we should expect the error property to be defined
+      prop2: action.error,
+    };
+  }
+}
+
+/*
+ * Test: isFulfilled correctly narrows types
+ */
+function isFulfilledTest(action: AnyAction) {
+  const thunk = createAsyncThunk<string>('a', () => 'result')
+
+  if (isFulfilled(thunk)(action)) {
+    return {
+      // we should expect the payload to be defined (in this case a string)
+      prop1: action.payload.charAt(0),
+      // we should not expect an error property
+      // typings:expect-error
+      prop2: action.error,
+    };
+  }
+}
+
+/*
+ * Test: isAsyncThunkAction correctly narrows types
+ */
+function isAsyncThunkActionTest(action: AnyAction) {
+  const thunk = createAsyncThunk<string>('a', () => 'result')
+
+  if (isAsyncThunkAction(thunk)(action)) {
+    return {
+      // we should expect the payload to be defined ...
+      prop1a: action.payload,
+      // ... but of unknown type because the action may be pending/rejected
+      // typings:expect-error
+      prop1b: action.payload.charAt(0),
+      // do not expect an error property because pending/fulfilled lack it
+      // typings:expect-error
+      prop2: action.error,
+    };
+  }
+}
+
+/*
+ * Test: isRejectedWithValue correctly narrows types
+ */
+function isRejectedWithValueTest(action: AnyAction) {
+  const thunk = createAsyncThunk<string>('a', () => 'result')
+
+  if (isRejectedWithValue(thunk)(action)) {
+    return {
+      // we should expect the payload to be defined ...
+      prop1a: action.payload,
+      // ... but of unknown type
+      // typings:expect-error
+      prop1b: action.payload.charAt(0),
+      // we should expect the error property to be defined
+      prop2: action.error,
+    };
+  }
+}