Repository: reduxjs/redux-toolkit
Pull request #788 (2020-11-02 01:34:31)

isAnyOf/isAllOf HOFs for simple matchers
Implementation for features requested in https://github.com/reduxjs/redux-toolkit/issues/771, based on discussion there.

Provides higher order functions for matching actions against many type guards or action creators.

Example usage:

```ts
const actionA = createAction<string>('a')
const actionB = createAction<number>('b')
const thunkC = createAsyncThunk<string>('c', () => {
  return 'noop'
})

if (isAnyOf(actionA, actionB, thunkC.fulfilled)(myAction)) {
 // myAction has type-guarded correct type
}

// and can also be used with builder.addMatcher()
const reducer = createReducer(
  initialState,
  (builder) => {
    builder.addMatcher(isAnyOf(actionA, actionB), (state, action) => {
      // reduce
    })
  }
);
```

Currently implements only core functionality.

netlify[bot]: Deploy preview for *redux-starter-kit-docs* ready!

Built with commit 52dbf1f127c5648514e6c6125afd8848527c786b

https://deploy-preview-788--redux-starter-kit-docs.netlify.app
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/788/builds/68906) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 52dbf1f127c5648514e6c6125afd8848527c786b:

|Sandbox| Source |
|--|--|
|[Vanilla](https://codesandbox.io/s/vanilla-3c2s6)| Configuration |
|[Vanilla Typescript](https://codesandbox.io/s/vanilla-typescript-8tmoy)| Configuration |
|[rsk-github-issues-example](https://codesandbox.io/s/rsk-github-issues-example-3zz80)| Configuration |

markerikson: Looks nice!

Two minor nitpicky questions:

- Seems like we could probably just put both of these into a `matchers.ts` file together
- At the risk of trying to de-duplicate things too much: it looks like the only difference here _code_-wise is the `.some` vs `.every` (although I see the type assertions are different).  Any way this could be de-duplicated?  factory-ized or something, maybe?  No big deal if not, just thought I'd check
douglas-treadwell: @markerikson

I can definitely put these both in `matchers.ts` if you'd prefer.  I was trying to follow what seemed to be a convention of one exported function per file.  Please confirm and I'll update the PR.

About the de-duplication, what I could de-duplicate is the inner...

```ts
if (hasMatchFunction(matcher)) {
  return matcher.match(action)
} else {
  return matcher(action)
}
```

... as a `matches` function, so that they would look like...

```ts
const matches = (matcher: Matcher<any>, action: any) => {
  if (hasMatchFunction(matcher)) {
    return matcher.match(action)
  } else {
    return matcher(action)
  }
}

export function isAnyOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(
  ...matchers: Matchers
) {
  return (action: any): action is ActionMatchingAnyOf<Matchers> => {
    return matchers.some((matcher) => matches(matcher, action));
  }
}

export function isAllOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(
  ...matchers: Matchers
) {
  return (action: any): action is ActionMatchingAllOf<Matchers> => {
    return matchers.every((matcher) => matches(matcher, action));
  }
}
```

If I combine them into `matchers.ts` the helper function `matches` would be local to both of them.
douglas-treadwell: @markerikson 

PR is updated.  After de-duplicating the code, moving them into the same file made a lot of sense.
markerikson: Yeahhhhh, I like the way that looks :)

We haven't had a specific "one function per file" convention - it's just sort of ended up that way because we have some fairly sizeable implementations and types for each of our core APIs.

Obviously this isn't a _huge_ change, but it does save a few bytes in the final output.
markerikson: Just out of curiosity, could you use an optional chaining check there instead of a try/catch?
douglas-treadwell: > Just out of curiosity, could you use an optional chaining check there instead of a try/catch?

That should be fine.  I'm guessing you're concerned about potential performance issues?

I tried that, but the build fails with **`Support for the experimental syntax 'optionalChaining' isn't currently enabled`**.

I could do the manual equivalent if you prefer.  I was erring on the side of being careful about not breaking things.
douglas-treadwell: In theory, the original code was perfectly safe.  The only arguments that could be passed in were either a function, or an object with a `match` property, so casting and checking the `match` property should not cause a runtime error.  If you like, I can revert the latest commit.
phryneas: > Or do you prefer to let an exception be thrown so they'll recognize their mistake?

I'm usually be all in for an error to be thrown early in development opposed to something like this staying hidden because of some graceful recovery - so I say just let it throw.


douglas-treadwell: > So far, this looks very good. Could you add some type tests as well? (See the separate `type-tests` folder.

Of course.  Are there any specific cases I should make sure to cover?

I've also replaced the `try/catch` with the exception prevention you suggested.
phryneas: Nothing specific comes to mind - check the [type tests for `createAction`](https://github.com/reduxjs/redux-toolkit/blob/master/type-tests/files/createAction.typetest.ts#L258).

One thing: maybe add extra tests that the combined matchers don't widen the type to `any`. It's a bit tricky to test that, but something along the lines
```ts
// typings:expect-error
const actionNotAny: IsAny<typeof action, true, false> = false
```
should so the trick.
Or just try to access properties that should not be on there and expect an error.
douglas-treadwell: It's been a busy day.  I'll be able to add the type tests tomorrow or the day after at the latest.
douglas-treadwell: @phryneas @markerikson

I've added some type tests for `isAnyOf` and `isAllOf` for the scenarios of: 1) type guards, 2) action creators, and 3) action creators belonging to async thunks.

I'm surprised I haven't heard of `typings-tester` before.  It seems like an incredibly useful tool.

Patch:
diff --git a/etc/redux-toolkit.api.md b/etc/redux-toolkit.api.md
index 207509fe..16427695 100644
--- a/etc/redux-toolkit.api.md
+++ b/etc/redux-toolkit.api.md
@@ -51,6 +51,12 @@ export interface ActionCreatorWithPreparedPayload<Args extends unknown[], P, T e
     (...args: Args): PayloadAction<P, T, M, E>;
 }
 
+// @public (undocumented)
+export type ActionMatchingAllOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]> = UnionToIntersection<ActionMatchingAnyOf<Matchers>>;
+
+// @public (undocumented)
+export type ActionMatchingAnyOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]> = ActionFromMatcher<Matchers[number]>;
+
 // @public
 export interface ActionReducerMapBuilder<State> {
     addCase<ActionCreator extends TypedActionCreator<string>>(actionCreator: ActionCreator, reducer: CaseReducer<State, ReturnType<ActionCreator>>): ActionReducerMapBuilder<State>;
@@ -295,6 +301,12 @@ export interface ImmutableStateInvariantMiddlewareOptions {
     warnAfter?: number;
 }
 
+// @public
+export function isAllOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(...matchers: Matchers): (action: any) => action is UnionToIntersection<ActionFromMatcher<Matchers[number]>>;
+
+// @public
+export function isAnyOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(...matchers: Matchers): (action: any) => action is ActionFromMatcher<Matchers[number]>;
+
 // @public
 export function isImmutableDefault(value: unknown): boolean;
 
diff --git a/src/index.ts b/src/index.ts
index d33fc5f5..d31b3bd3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -106,4 +106,13 @@ export {
   SerializedError
 } from './createAsyncThunk'
 
+export {
+  // js
+  isAllOf,
+  isAnyOf,
+  // types
+  ActionMatchingAllOf,
+  ActionMatchingAnyOf
+} from './matchers'
+
 export { nanoid } from './nanoid'
diff --git a/src/matchers.ts b/src/matchers.ts
new file mode 100644
index 00000000..77c467c8
--- /dev/null
+++ b/src/matchers.ts
@@ -0,0 +1,58 @@
+import {
+  ActionFromMatcher,
+  hasMatchFunction,
+  Matcher,
+  UnionToIntersection
+} from './tsHelpers'
+
+/** @public */
+export type ActionMatchingAnyOf<
+  Matchers extends [Matcher<any>, ...Matcher<any>[]]
+> = ActionFromMatcher<Matchers[number]>
+
+/** @public */
+export type ActionMatchingAllOf<
+  Matchers extends [Matcher<any>, ...Matcher<any>[]]
+> = UnionToIntersection<ActionMatchingAnyOf<Matchers>>
+
+const matches = (matcher: Matcher<any>, action: any) => {
+  if (hasMatchFunction(matcher)) {
+    return matcher.match(action)
+  } else {
+    return matcher(action)
+  }
+}
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action matches any one of the supplied type guards or action
+ * creators.
+ *
+ * @param matchers The type guards or action creators to match against.
+ *
+ * @public
+ */
+export function isAnyOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(
+  ...matchers: Matchers
+) {
+  return (action: any): action is ActionMatchingAnyOf<Matchers> => {
+    return matchers.some(matcher => matches(matcher, action))
+  }
+}
+
+/**
+ * A higher-order function that returns a function that may be used to check
+ * whether an action matches all of the supplied type guards or action
+ * creators.
+ *
+ * @param matchers The type guards or action creators to match against.
+ *
+ * @public
+ */
+export function isAllOf<Matchers extends [Matcher<any>, ...Matcher<any>[]]>(
+  ...matchers: Matchers
+) {
+  return (action: any): action is ActionMatchingAllOf<Matchers> => {
+    return matchers.every(matcher => matches(matcher, action))
+  }
+}
diff --git a/src/tsHelpers.ts b/src/tsHelpers.ts
index 43ec40a3..68eabefb 100644
--- a/src/tsHelpers.ts
+++ b/src/tsHelpers.ts
@@ -84,7 +84,7 @@ export type DispatchForMiddlewares<M> = M extends ReadonlyArray<any>
 /**
  * Convert a Union type `(A|B)` to and intersecion type `(A&B)`
  */
-type UnionToIntersection<U> = (U extends any
+export type UnionToIntersection<U> = (U extends any
 ? (k: U) => void
 : never) extends (k: infer I) => void
   ? I
@@ -100,3 +100,23 @@ type UnionToIntersection<U> = (U extends any
 export type NoInfer<T> = [T][T extends any ? 0 : never]
 
 export type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
+
+export interface HasMatchFunction<T> {
+  match(v: any): v is T
+}
+
+export const hasMatchFunction = <T>(
+  v: Matcher<T>
+): v is HasMatchFunction<T> => {
+  return v && typeof (v as HasMatchFunction<T>).match === 'function'
+}
+
+/** @public */
+export type Matcher<T> = HasMatchFunction<T> | ((v: any) => v is T)
+
+/** @public */
+export type ActionFromMatcher<M extends Matcher<any>> = M extends Matcher<
+  infer T
+>
+  ? T
+  : never


Test patch:
diff --git a/src/matchers.test.ts b/src/matchers.test.ts
new file mode 100644
index 00000000..0136b2cf
--- /dev/null
+++ b/src/matchers.test.ts
@@ -0,0 +1,133 @@
+import { isAllOf, isAnyOf } from './matchers'
+import { createAction } from './createAction'
+import { createAsyncThunk } from './createAsyncThunk'
+import { createReducer } from './createReducer'
+
+describe('isAnyOf', () => {
+  it('returns true only if any matchers match (match function)', () => {
+    const actionA = createAction<string>('a')
+    const actionB = createAction<number>('b')
+
+    const trueAction = {
+      type: 'a',
+      payload: 'payload'
+    }
+
+    expect(isAnyOf(actionA, actionB)(trueAction)).toEqual(true)
+
+    const falseAction = {
+      type: 'c',
+      payload: 'payload'
+    }
+
+    expect(isAnyOf(actionA, actionB)(falseAction)).toEqual(false)
+  })
+
+  it('returns true only if any type guards match', () => {
+    const actionA = createAction<string>('a')
+    const actionB = createAction<number>('b')
+
+    const isActionA = actionA.match
+    const isActionB = actionB.match
+
+    const trueAction = {
+      type: 'a',
+      payload: 'payload'
+    }
+
+    expect(isAnyOf(isActionA, isActionB)(trueAction)).toEqual(true)
+
+    const falseAction = {
+      type: 'c',
+      payload: 'payload'
+    }
+
+    expect(isAnyOf(isActionA, isActionB)(falseAction)).toEqual(false)
+  })
+
+  it('returns true only if any matchers match (thunk action creators)', () => {
+    const thunkA = createAsyncThunk<string>('a', () => {
+      return 'noop'
+    })
+    const thunkB = createAsyncThunk<number>('b', () => {
+      return 0
+    })
+
+    const action = thunkA.fulfilled('fakeRequestId', 'test')
+
+    expect(isAnyOf(thunkA.fulfilled, thunkB.fulfilled)(action)).toEqual(true)
+
+    expect(
+      isAnyOf(thunkA.pending, thunkA.rejected, thunkB.fulfilled)(action)
+    ).toEqual(false)
+  })
+
+  it('works with reducers', () => {
+    const actionA = createAction<string>('a')
+    const actionB = createAction<number>('b')
+
+    const trueAction = {
+      type: 'a',
+      payload: 'payload'
+    }
+
+    const initialState = { value: false }
+
+    const reducer = createReducer(initialState, builder => {
+      builder.addMatcher(isAnyOf(actionA, actionB), state => {
+        return { ...state, value: true }
+      })
+    })
+
+    expect(reducer(initialState, trueAction)).toEqual({ value: true })
+
+    const falseAction = {
+      type: 'c',
+      payload: 'payload'
+    }
+
+    expect(reducer(initialState, falseAction)).toEqual(initialState)
+  })
+})
+
+describe('isAllOf', () => {
+  it('returns true only if all matchers match', () => {
+    const actionA = createAction<string>('a')
+
+    interface SpecialAction {
+      payload: 'SPECIAL'
+    }
+
+    const isActionSpecial = (action: any): action is SpecialAction => {
+      return action.payload === 'SPECIAL'
+    }
+
+    const trueAction = {
+      type: 'a',
+      payload: 'SPECIAL'
+    }
+
+    expect(isAllOf(actionA, isActionSpecial)(trueAction)).toEqual(true)
+
+    const falseAction = {
+      type: 'a',
+      payload: 'ORDINARY'
+    }
+
+    expect(isAllOf(actionA, isActionSpecial)(falseAction)).toEqual(false)
+
+    const thunkA = createAsyncThunk<string>('a', () => 'result')
+
+    const specialThunkAction = thunkA.fulfilled('SPECIAL', 'fakeRequestId')
+
+    expect(isAllOf(thunkA.fulfilled, isActionSpecial)(specialThunkAction)).toBe(
+      true
+    )
+
+    const ordinaryThunkAction = thunkA.fulfilled('ORDINARY', 'fakeRequestId')
+
+    expect(
+      isAllOf(thunkA.fulfilled, isActionSpecial)(ordinaryThunkAction)
+    ).toBe(false)
+  })
+})
diff --git a/type-tests/files/matchers.typetest.ts b/type-tests/files/matchers.typetest.ts
new file mode 100644
index 00000000..a36cb760
--- /dev/null
+++ b/type-tests/files/matchers.typetest.ts
@@ -0,0 +1,205 @@
+import { AnyAction } from 'redux'
+import { createAction, createAsyncThunk, isAllOf, isAnyOf } from '../../src'
+
+/* isAnyOf */
+
+/*
+ * Test: isAnyOf correctly narrows types when used with action creators
+ */
+function isAnyOfActionTest(action: AnyAction) {
+  const actionA = createAction('a', () => {
+    return {
+      payload: {
+        prop1: 1,
+        prop3: 2
+      }
+    };
+  })
+
+  const actionB = createAction('b', () => {
+    return {
+      payload: {
+        prop1: 1,
+        prop2: 2
+      }
+    };
+  })
+
+  if (isAnyOf(actionA, actionB)(action)) {
+    return {
+      prop1: action.payload.prop1,
+      // typings:expect-error
+      prop2: action.payload.prop2,
+      // typings:expect-error
+      prop3: action.payload.prop3
+    };
+  }
+}
+
+/*
+ * Test: isAnyOf correctly narrows types when used with async thunks
+ */
+function isAnyOfThunkTest(action: AnyAction) {
+  const asyncThunk1 = createAsyncThunk<{prop1: number, prop3: number}>(
+    'asyncThunk1',
+
+    async () => {
+      return {
+        prop1: 1,
+        prop3: 3
+      };
+    }
+  );
+
+  const asyncThunk2 = createAsyncThunk<{prop1: number, prop2: number}>(
+    'asyncThunk2',
+
+    async () => {
+      return {
+        prop1: 1,
+        prop2: 2
+      };
+    }
+  );
+
+  if (isAnyOf(asyncThunk1.fulfilled, asyncThunk2.fulfilled)(action)) {
+    return {
+      prop1: action.payload.prop1,
+      // typings:expect-error
+      prop2: action.payload.prop2,
+      // typings:expect-error
+      prop3: action.payload.prop3
+    };
+  }
+}
+
+/*
+ * Test: isAnyOf correctly narrows types when used with type guards
+ */
+function isAnyOfTypeGuardTest(action: AnyAction) {
+  interface ActionA {
+    type: 'a';
+    payload: {
+      prop1: 1,
+      prop3: 2
+    };
+  }
+
+  interface ActionB {
+    type: 'b';
+    payload: {
+      prop1: 1,
+      prop2: 2
+    }
+  }
+
+  const guardA = (v: any): v is ActionA => {
+    return v.type === 'a';
+  }
+
+  const guardB = (v: any): v is ActionB => {
+    return v.type === 'b';
+  }
+
+
+  if (isAnyOf(guardA, guardB)(action)) {
+    return {
+      prop1: action.payload.prop1,
+      // typings:expect-error
+      prop2: action.payload.prop2,
+      // typings:expect-error
+      prop3: action.payload.prop3
+    };
+  }
+}
+
+/* isAllOf */
+
+interface SpecialAction {
+  payload: {
+    special: boolean;
+  }
+}
+
+const isSpecialAction = (v: any): v is SpecialAction => {
+  return v.meta.isSpecial;
+}
+
+/*
+ * Test: isAllOf correctly narrows types when used with action creators
+ *       and type guards
+ */
+function isAllOfActionTest(action: AnyAction) {
+  const actionA = createAction('a', () => {
+    return {
+      payload: {
+        prop1: 1,
+        prop3: 2
+      }
+    };
+  })
+
+  if (isAllOf(actionA, isSpecialAction)(action)) {
+    return {
+      prop1: action.payload.prop1,
+      // typings:expect-error
+      prop2: action.payload.prop2,
+      prop3: action.payload.prop3,
+      special: action.payload.special
+    };
+  }
+}
+
+/*
+ * Test: isAllOf correctly narrows types when used with async thunks
+ *       and type guards
+ */
+function isAllOfThunkTest(action: AnyAction) {
+  const asyncThunk1 = createAsyncThunk<{prop1: number, prop3: number}>(
+    'asyncThunk1',
+
+    async () => {
+      return {
+        prop1: 1,
+        prop3: 3
+      };
+    }
+  );
+
+  if (isAllOf(asyncThunk1.fulfilled, isSpecialAction)(action)) {
+    return {
+      prop1: action.payload.prop1,
+      // typings:expect-error
+      prop2: action.payload.prop2,
+      prop3: action.payload.prop3,
+      special: action.payload.special
+    };
+  }
+}
+
+/*
+ * Test: isAnyOf correctly narrows types when used with type guards
+ */
+function isAllOfTypeGuardTest(action: AnyAction) {
+  interface ActionA {
+    type: 'a';
+    payload: {
+      prop1: 1,
+      prop3: 2
+    };
+  }
+
+  const guardA = (v: any): v is ActionA => {
+    return v.type === 'a';
+  }
+
+  if (isAllOf(guardA, isSpecialAction)(action)) {
+    return {
+      prop1: action.payload.prop1,
+      // typings:expect-error
+      prop2: action.payload.prop2,
+      prop3: action.payload.prop3,
+      special: action.payload.special
+    };
+  }
+}