diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index e097dd46..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: "Code scanning - action" - -on: - push: - branches: - - master - pull_request: - schedule: - - cron: '0 13 * * 4' - -jobs: - CodeQL-Build: - - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/package.json b/package.json index 6dc15b70..8712bec9 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "react": "18.2.0", "react-test-renderer": "18.2.0", "rimraf": "3.0.2", - "rxjs": "7.5.7", + "rxjs": "7.8.0", "rxjs-marbles": "7.0.1", "standard-version": "9.5.0", "typescript": "4.9.3" diff --git a/src/action$.spec.ts b/src/action$.spec.ts index c8de2b77..3df00a3c 100644 --- a/src/action$.spec.ts +++ b/src/action$.spec.ts @@ -2,7 +2,7 @@ import { incrementMocks } from './internal/testing/mock'; import { action$, dispatchAction } from './action$'; import { take } from 'rxjs/operators'; import { _namespaceAction } from './namespace'; -import { ActionWithoutPayload } from './types/Action'; +import { UnknownAction } from './internal'; const actions = incrementMocks.marbles.actions; @@ -13,7 +13,7 @@ afterEach(() => { }); test('dispatchAction makes action$ emit', () => { - let lastAction: ActionWithoutPayload | undefined; + let lastAction: UnknownAction | undefined; const sub = action$.pipe(take(1)).subscribe((a) => (lastAction = a)); cleanupFns.push(() => sub.unsubscribe()); @@ -22,7 +22,7 @@ test('dispatchAction makes action$ emit', () => { }); test('dispatchAction does not remove existing namespace', () => { - let lastAction: ActionWithoutPayload | undefined; + let lastAction: UnknownAction | undefined; const sub = action$.pipe(take(1)).subscribe((a) => (lastAction = a)); cleanupFns.push(() => sub.unsubscribe()); diff --git a/src/actionCreator.spec.ts b/src/actionCreator.spec.ts index 5cebe41a..88b9fbca 100644 --- a/src/actionCreator.spec.ts +++ b/src/actionCreator.spec.ts @@ -3,11 +3,12 @@ import { isActionOfType, isValidRxBeachAction, } from './actionCreator'; +import { ActionName } from './types/Action'; type Payload = { num: number }; const myAction = actionCreator('[test] three'); const action = myAction({ num: 3 }) as { - type: string; + type: ActionName; payload: Payload; meta: { namespace?: string }; }; @@ -32,7 +33,7 @@ test('actionCreator should create action objects with the payload', () => { }); test('actionCreator should create action objects with protected type', () => { expect(() => { - action.type = 'mock'; + action.type = '[test] mock'; }).toThrow(TypeError); }); test('actionCreator should create action objects with protected meta', () => { diff --git a/src/actionCreator.ts b/src/actionCreator.ts index ef3d1c48..50e24eed 100644 --- a/src/actionCreator.ts +++ b/src/actionCreator.ts @@ -1,15 +1,19 @@ -import type { Action, VoidPayload } from './types/Action'; -import type { ActionCreator, ActionCreatorFunc } from './types/ActionCreator'; +import type { Action, ActionName, VoidPayload } from './types/Action'; +import type { ActionCreator } from './types/ActionCreator'; /** - * Untyped `actionCreator` + * Create an action creator * - * **You code should not hit this untyped overload** - * If you see this message in your IDE, you should investigate why TS did not - * recognize the generic, typed overload of this function. + * @param type A name for debugging purposes + * @template `Payload` - The payload type for the action + * @returns An action creator function that accepts a payload as input, and + * returns a complete action object with that payload and a type unique + * to this action creator */ -export const actionCreator: ActionCreatorFunc = (type: string) => { - const actionCreatorFn = (payload?: any) => +export const actionCreator = ( + type: ActionName +): ActionCreator => { + const actionCreatorFn = (payload: Payload) => Object.freeze({ type, payload, diff --git a/src/actionCreator.tspec.ts b/src/actionCreator.tspec.ts index 2c86506d..0e495f1f 100644 --- a/src/actionCreator.tspec.ts +++ b/src/actionCreator.tspec.ts @@ -102,6 +102,27 @@ for (const [creatorFn, anAction] of actionPairs) { const action1 = actionCreatorWithoutPayload(); if (isActionOfType(actionCreatorWithoutPayload, action1)) { // @ts-expect-error Assert that ActionWithoutPayload does not have a payload - // eslint-disable-next-line no-unused-expressions - action1.payload; + isNonNullish(action1.payload); } + +// eslint-disable-next-line @typescript-eslint/ban-types +const isNonNullish = (x: {}) => { + /* no-op */ +}; + +const testIsActionOfType = (action: any) => { + if (!isValidRxBeachAction(action)) { + return; + } + + if (isActionOfType(actionCreatorWithPayload, action)) { + return isNonNullish(action.payload); + } + + if (isActionOfType(actionCreatorWithoutPayload, action)) { + // @ts-expect-error should not have payload + return isNonNullish(action.payload); + } + + isNonNullish(action.type); +}; diff --git a/src/internal/testing/utils.ts b/src/internal/testing/utils.ts index a903ccb6..abe9a39f 100644 --- a/src/internal/testing/utils.ts +++ b/src/internal/testing/utils.ts @@ -1,8 +1,8 @@ -import { Action } from '../../types/Action'; +import type { Action, ActionName } from '../../types/Action'; import { VoidPayload } from '../types'; export const mockAction =

( - type: string, + type: ActionName, namespace?: string, payload?: P ): Action

=> diff --git a/src/internal/types.ts b/src/internal/types.ts index 5bdb34e2..c65ea66d 100644 --- a/src/internal/types.ts +++ b/src/internal/types.ts @@ -1,5 +1,5 @@ -import { OperatorFunction } from 'rxjs'; -import { ActionWithPayload, ActionWithoutPayload } from '../types/Action'; +import type { OperatorFunction } from 'rxjs'; +import type { Action, ActionName, ActionWithPayload } from '../types/Action'; export { VoidPayload } from '../types/Action'; @@ -10,10 +10,10 @@ export { VoidPayload } from '../types/Action'; * want to extract a possible `payload` from an action you don't know anything * about. */ -export type UnknownAction = ActionWithoutPayload & { payload?: unknown }; +export type UnknownAction = Action; export interface ActionCreatorCommon { - readonly type: string; + readonly type: ActionName; } /** diff --git a/src/internal/types.tspec.ts b/src/internal/types.tspec.ts index 6890ea18..1655aa7d 100644 --- a/src/internal/types.tspec.ts +++ b/src/internal/types.tspec.ts @@ -1,4 +1,3 @@ -import { AssertFalse, Has } from 'conditional-type-checks'; import { ActionWithPayload, ActionWithoutPayload } from '../types/Action'; import { ActionCreatorWithPayload, @@ -24,11 +23,6 @@ let unknownActionCreator: UnknownActionCreator; unknownAction = actionWithoutPayload; unknownAction = actionWithPayload; -type ActionCreatorWithoutPayload_is_not_assibleable_to_UnknownActionCreatorWithPayload = - AssertFalse< - Has> - >; - // ActionCreatorWithPayload and UnkownActionCreatorWithPayload is assignable to each other unknownActionCreatorWithPayload = actionCreatorWithPayload; actionCreatorWithPayload = unknownActionCreatorWithPayload; diff --git a/src/namespace.spec.ts b/src/namespace.spec.ts index 1144c9df..a5a8f5d5 100644 --- a/src/namespace.spec.ts +++ b/src/namespace.spec.ts @@ -3,16 +3,17 @@ import { ActionDispatcher } from './types/helpers'; import { UnknownAction } from './internal/types'; import { _namespaceAction } from './namespace'; import { mockAction } from './internal/testing/utils'; +import { ActionName } from './types/Action'; -const namespaced = _namespaceAction('namespace', mockAction('type')) as { - type: string; +const namespaced = _namespaceAction('namespace', mockAction('[Mock] type')) as { + type: ActionName; meta: { namespace: string; }; }; test('_namespaceAction has unwritable type', () => { expect(() => { - namespaced.type = 'foo'; + namespaced.type = '[Mock] foo'; }).toThrow(TypeError); }); @@ -29,7 +30,7 @@ test('_namespaceAction has unwritable namespace', () => { }); test('namespaceActionCreator should create actions with namespace', () => { - const type = 'action type'; + const type: ActionName = '[Mock] action type'; const namespace = 'new namespace'; const actionCreator = (payload: number) => mockAction(type, 'old namespace', payload); @@ -55,7 +56,7 @@ test('namespaceActionDispatcher should invoke the parent dispatcher with namespa parentDispatcher ); - const actionObject = mockAction('action', 'old namespace'); + const actionObject = mockAction('[Mock] action', 'old namespace'); childDispatcher(actionObject); diff --git a/src/operators/operators.spec.ts b/src/operators/operators.spec.ts index 3623d818..e438c799 100644 --- a/src/operators/operators.spec.ts +++ b/src/operators/operators.spec.ts @@ -30,7 +30,7 @@ test.each` ({ payload }: { name: string; payload: unknown }) => { marbles((m) => { const source = m.hot>('aa', { - a: mockAction('', '', payload) as ActionWithPayload, + a: mockAction('[Mock] a', '', payload), }); const expected = m.hot('pp', { p: payload, diff --git a/src/persistentDerivedStream.ts b/src/persistentDerivedStream.ts index e380e554..22d17bb4 100644 --- a/src/persistentDerivedStream.ts +++ b/src/persistentDerivedStream.ts @@ -1,4 +1,4 @@ -import { ObservableInput } from 'rxjs'; +import type { ObservableInput } from 'rxjs'; import { ObservableState } from './observableState'; import { stateStreamRegistry } from './stateStreamRegistry'; diff --git a/src/routines.spec.ts b/src/routines.spec.ts index 5a159e4c..5f59d1b6 100644 --- a/src/routines.spec.ts +++ b/src/routines.spec.ts @@ -1,7 +1,7 @@ import { Subject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { marbles } from 'rxjs-marbles/jest'; -import { ActionWithPayload } from './types/Action'; +import { Action } from './types/Action'; import { Routine, collectRoutines, @@ -18,30 +18,36 @@ beforeAll(() => { jest.useFakeTimers(); }); -const actions = { - a: mockAction('alpha', undefined, { letter: 'A' }), - b: mockAction('bravo', undefined, { letter: 'B' }), - c: mockAction('charlie', undefined, { letter: 'C' }), - e: mockAction('error'), +const marbleMap: Record = { + a: mockAction('[Mock] alpha', undefined, { letter: 'A' }), + b: mockAction('[Mock] bravo', undefined, { letter: 'B' }), + c: mockAction('[Mock] charlie', undefined, { letter: 'C' }), + e: mockAction('[Mock] error'), f: mockAction('[Mock] action', undefined, { letter: 'F' }), }; -const letters = { - A: 'A', - B: 'B', - C: 'C', - F: 'F', -}; -const lengths = { - ...letters, - '5': 5, - '7': 7, -}; + +// Add letter representations to marble map +for (let i = 'A'.charCodeAt(0); i < 'Z'.charCodeAt(0); i++) { + const letter = String.fromCharCode(i); + marbleMap[letter] = letter; +} +// Add number representations to marble map +for (let i = 0; i < 10; i++) { + // Create marble representations of the 10 first characters + // eg. { A: 'A', B: 'B', ...etc } + const charCodeA = 'A'.charCodeAt(0); + const letter = String.fromCharCode(charCodeA + i); + marbleMap[letter] = letter; + // Create marble representations of the 10 first numbers from 0 + marbleMap[`${i}`] = i; +} + const errors = { e: 'error', }; const actionMarbles1 = ' -a----b----c---'; const letterMarbles = ' -A----B----C---'; -const combinedMarbles = '-(A5)-(B5)-(C7)'; +const combinedMarbles = '-(A0)-(B1)-(C2)'; const actionMarbles2 = ' -a----e----a---'; const errorMarbles = ' ------e'; const errorSub1 = ' ^-----!'; @@ -49,24 +55,30 @@ const errorSub2 = ' ------^--------'; const singleActionMarble = ' -f---f'; const errorRoutine: Routine = map((a) => { - if (a.type === 'error') throw 'error'; + if (a.type === '[Mock] error') throw 'error'; return 'passed'; }); const lettersRoutine = routine( - filter( - (action: any): action is ActionWithPayload<{ letter: string }> => - action.payload !== undefined - ), + filter((action: unknown): action is Action<{ letter: string }> => { + const { payload } = action as Action<{ letter: string }>; + return Boolean(payload.letter); + }), extractPayload(), map(({ letter }) => letter) ); -const lengthRoutine = routine(map(({ type }) => type.length)); + +const letterToNumberRoutine = routine( + map((action) => { + const { payload } = action as Action<{ letter: string }>; + return payload.letter.charCodeAt(0) - 'A'.charCodeAt(0); + }) +); test( 'routine pipes multiple operator functions', marbles((m) => { - const action$ = m.hot(actionMarbles1, actions); - const expected$ = m.hot(letterMarbles, letters); + const action$ = m.hot(actionMarbles1, marbleMap); + const expected$ = m.hot(letterMarbles, marbleMap); const actual$ = action$.pipe(lettersRoutine); @@ -77,10 +89,10 @@ test( test( 'collectRoutines runs all routines', marbles((m) => { - const action$ = m.hot(actionMarbles1, actions); - const expected$ = m.hot(combinedMarbles, lengths); + const action$ = m.hot(actionMarbles1, marbleMap); + const expected$ = m.hot(combinedMarbles, marbleMap); const actual$ = action$.pipe( - collectRoutines(lettersRoutine, lengthRoutine) + collectRoutines(lettersRoutine, letterToNumberRoutine) ); m.expect(actual$).toBeObservable(expected$); }) @@ -89,7 +101,7 @@ test( test( 'subscribeRoutine subscribes action$', marbles((m) => { - const action$ = m.hot(actionMarbles1, actions); + const action$ = m.hot(actionMarbles1, marbleMap); subscribeRoutine(action$, lettersRoutine); m.expect(action$).toHaveSubscriptions(['^']); @@ -99,7 +111,7 @@ test( test( 'subscribeRoutine resubscribes on errors', marbles((m) => { - const action$ = m.hot(actionMarbles2, actions); + const action$ = m.hot(actionMarbles2, marbleMap); subscribeRoutine(action$, errorRoutine); @@ -110,7 +122,7 @@ test( test( 'subscribeRoutine emits errors to error subject', marbles((m) => { - const action$ = m.hot(actionMarbles2, actions); + const action$ = m.hot(actionMarbles2, marbleMap); const error$ = new Subject(); subscribeRoutine(action$, errorRoutine, error$); @@ -122,7 +134,7 @@ test( test( 'tapRoutine register a routine', marbles((m) => { - const action$ = m.hot(singleActionMarble, actions); + const action$ = m.hot(singleActionMarble, marbleMap); const action = actionCreator<{ letter: string }>('[Mock] action'); expect.assertions(2); const routineToSubscribe = tapRoutine(action, (payload) => diff --git a/src/types/Action.ts b/src/types/Action.ts index 4f3d1d3b..aa1f041a 100644 --- a/src/types/Action.ts +++ b/src/types/Action.ts @@ -1,26 +1,22 @@ +export type ActionName = `[${string}] ${string}`; + export type VoidPayload = void; type Meta = { readonly namespace?: string; }; -export type ActionWithoutPayload = { - readonly type: string; +export interface Action { + readonly type: ActionName; readonly meta: Meta; -}; - -export type ActionWithPayload = ActionWithoutPayload & { readonly payload: Payload; -}; +} /** - * A conditional type that dispatches between `ActionWithPayload` and - * `ActionWithoutPayload` by comparing the `Payload` type to `VoidPayload` - * - * Without a generic type, this defaults to `ActionWithoutPayload`. - * - * @template `Payload` - The payload type to dispatch on + * @deprecated use Action instead + */ +export type ActionWithoutPayload = Action; +/** + * @deprecated use Action instead */ -export type Action = [Payload] extends [VoidPayload] - ? ActionWithoutPayload - : ActionWithPayload; +export type ActionWithPayload = Action; diff --git a/src/types/Action.tspec.ts b/src/types/Action.tspec.ts index 429896c7..df7424a2 100644 --- a/src/types/Action.tspec.ts +++ b/src/types/Action.tspec.ts @@ -2,7 +2,7 @@ import { AssertFalse, AssertTrue, Has, IsExact } from 'conditional-type-checks'; import { Action, ActionWithPayload, ActionWithoutPayload } from './Action'; type ActionWithPayload_extends_ActionWithoutPayload = AssertTrue< - Has, ActionWithoutPayload> + Has, ActionWithoutPayload> >; type Action_dispatches_to_ActionWithPayload = AssertTrue< diff --git a/src/types/ActionCreator.ts b/src/types/ActionCreator.ts index 498ab7f9..3b25ce0a 100644 --- a/src/types/ActionCreator.ts +++ b/src/types/ActionCreator.ts @@ -1,46 +1,21 @@ -import { ActionWithPayload, ActionWithoutPayload } from './Action'; -import { ActionCreatorCommon, VoidPayload } from '../internal/types'; - -export interface ActionCreatorWithoutPayload extends ActionCreatorCommon { - (): ActionWithoutPayload; -} - -export interface ActionCreatorWithPayload extends ActionCreatorCommon { - (payload: Payload): ActionWithPayload; -} +import type { Action, ActionName } from './Action'; /** - * A conditional type that dispatches between `ActionCreatorWithPayload` and - * `ActionCreatorWithoutPayload` by comparing the `Payload` type to - * `VoidPayload` - * - * Without a generic type, this defaults to `ActionCreatorWithoutPayload`. + * Type of the action creator functions * * @template `Payload` - The payload type to dispatch on */ -export type ActionCreator = Payload extends VoidPayload - ? ActionCreatorWithoutPayload - : ActionCreatorWithPayload; +export interface ActionCreator { + (payload: Payload): Action; + type: ActionName; +} -type ActionName = `[${string}] ${string}`; +/** + * @deprecated use ActionCreator instead + */ +export type ActionCreatorWithoutPayload = ActionCreator; -export interface ActionCreatorFunc { - /** - * Create an action creator without a payload - * - * @param type A name for debugging purposes - * @returns An action creator function that creates complete action objects with - * a type unique to this action creator - */ - (type: ActionName): ActionCreatorWithoutPayload; - /** - * Create an action creator with a given payload type - * - * @param type A name for debugging purposes - * @template `Payload` - The payload type for the action - * @returns An action creator function that accepts a payload as input, and - * returns a complete action object with that payload and a type unique - * to this action creator - */ - (type: ActionName): ActionCreatorWithPayload; -} +/** + * @deprecated use ActionCreator instead + */ +export type ActionCreatorWithPayload = ActionCreator; diff --git a/src/types/helpers.tspec.ts b/src/types/helpers.tspec.ts index c42e8648..bfb41b4f 100644 --- a/src/types/helpers.tspec.ts +++ b/src/types/helpers.tspec.ts @@ -1,8 +1,34 @@ import { AssertTrue, IsExact } from 'conditional-type-checks'; -import { ActionCreatorWithPayload } from './ActionCreator'; -import { ExtractPayload } from './helpers'; +import { ActionCreator, ActionCreatorWithPayload } from './ActionCreator'; +import { ExtractPayload, InferPayloadFromActionCreator } from './helpers'; type Payload = { foo: number }; +type VoidFn = () => void; + type ExtractPayload_extracts_payload = AssertTrue< IsExact>, Payload> >; +type ExtractPayload_extracts_payload2 = AssertTrue< + IsExact, void> +>; +// type aaa = ExtractPayload; // weakness of ExtractPayload +// type aaa3 = ExtractPayload; // weakness of ExtractPayload + +type InferPayloadFromActionCreator_extracts_payload = AssertTrue< + IsExact>, Payload> +>; +type InferPayloadFromActionCreator_extracts_void_payload = AssertTrue< + IsExact, void> +>; +type InferPayloadFromActionCreator_handles_bad_input = AssertTrue< + IsExact, never> +>; +type InferPayloadFromActionCreator_handles_bad_input_2 = AssertTrue< + IsExact, never> +>; +type InferPayloadFromActionCreator_handles_unknown = AssertTrue< + IsExact, never> +>; +type InferPayloadFromActionCreator_handles_void = AssertTrue< + IsExact, never> +>; diff --git a/yarn.lock b/yarn.lock index cb6896a3..881fd4e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4969,10 +4969,10 @@ rxjs-spy@8.0.2: rxjs-report-usage "^1.0.4" stacktrace-gps "^3.0.2" -rxjs@7.5.7: - version "7.5.7" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" - integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== +rxjs@7.8.0: + version "7.8.0" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4" + integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg== dependencies: tslib "^2.1.0" @@ -5396,9 +5396,9 @@ tslib@^1.8.1: integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== tslib@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + version "2.5.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf" + integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg== tsutils@^3.21.0: version "3.21.0"