From d57fa8c7ad2ffdbb62511faddfc594da1f6efa45 Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:08:16 -0500 Subject: [PATCH 1/8] feat(component-store): enforce exact return types in updater callbacks The updater() method now enforces exact return types at the type level, preventing excess properties in callback return values without requiring explicit return type annotations. This eliminates the need for the updater-explicit-return-type ESLint rule, which is now deprecated. BREAKING CHANGES: Updater callbacks that return objects with properties not present in the state type will now produce TypeScript compilation errors. When ComponentStore is extended with a generic state type parameter (e.g. class MyStore extends ComponentStore), callbacks that spread state and override known properties may produce a false type error. Return state directly or use a type assertion (as T) in those cases. BEFORE: ```ts interface State { name: string; count: number } const store = new ComponentStore({ name: '', count: 0 }); // Previously compiled without error despite the excess property store.updater((state, name: string) => ({ ...state, name, extraProp: true, // no error })); ``` AFTER: ```ts // Now produces a TypeScript error: // Type 'boolean' is not assignable to type // '"updater callback return type must exactly match the state type. // Remove excess properties."' store.updater((state, name: string) => ({ ...state, name, extraProp: true, // TS error })); ``` Closes #4280 --- .../spec/types/component-store.types.spec.ts | 56 +++++++++++++++++++ .../spec/types/regression.types.spec.ts | 23 ++++++++ .../component-store/src/component-store.ts | 21 +++++-- .../updater-explicit-return-type.ts | 4 +- .../app/pages/guide/component-store/write.md | 28 ++++++++++ .../rules/updater-explicit-return-type.md | 5 ++ 6 files changed, 132 insertions(+), 5 deletions(-) diff --git a/modules/component-store/spec/types/component-store.types.spec.ts b/modules/component-store/spec/types/component-store.types.spec.ts index 3790e474a1..43782fcb75 100644 --- a/modules/component-store/spec/types/component-store.types.spec.ts +++ b/modules/component-store/spec/types/component-store.types.spec.ts @@ -273,5 +273,61 @@ describe('ComponentStore types', () => { ); }); }); + + describe('catches excess properties', () => { + it('when extra property is returned with spread', () => { + expectSnippet( + `componentStore.updater((state, v: string) => ({...state, extraProp: 'bad'}))('test');` + ).toFail(/Remove excess properties/); + }); + + it('when extra property is returned with explicit object', () => { + expectSnippet( + `componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2, extraProp: 'bad' }))('test');` + ).toFail(/Remove excess properties/); + }); + + it('when extra property is returned from void updater', () => { + expectSnippet( + `componentStore.updater((state) => ({...state, extraProp: true}))();` + ).toFail(/Remove excess properties/); + }); + + it('when required property is missing', () => { + expectSnippet( + `componentStore.updater((state, v: string) => ({ prop: v }))('test');` + ).toFail(/is missing in type/); + }); + + it('when property has wrong type', () => { + expectSnippet( + `componentStore.updater((state, v: string) => ({...state, prop: 123}))('test');` + ).toFail(/not assignable to type/); + }); + + it('allows spread with override', () => { + expectSnippet( + `const sub = componentStore.updater((state, v: string) => ({...state, prop: v}))('test');` + ).toInfer('sub', 'Subscription'); + }); + + it('allows full explicit return matching all state keys', () => { + expectSnippet( + `const sub = componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2 }))('test');` + ).toInfer('sub', 'Subscription'); + }); + + it('allows void updater with spread return', () => { + expectSnippet( + `const v = componentStore.updater((state) => ({...state, prop: 'updated'}))();` + ).toInfer('v', 'void'); + }); + + it('allows direct state return', () => { + expectSnippet( + `const v = componentStore.updater((state) => state)();` + ).toInfer('v', 'void'); + }); + }); }); }); diff --git a/modules/component-store/spec/types/regression.types.spec.ts b/modules/component-store/spec/types/regression.types.spec.ts index d0ebacd747..20f449077e 100644 --- a/modules/component-store/spec/types/regression.types.spec.ts +++ b/modules/component-store/spec/types/regression.types.spec.ts @@ -41,4 +41,27 @@ describe('regression component-store', () => { `; expectSnippet(effectTest).toSucceed(); }); + + describe('updater exact return type', () => { + it('should work with state containing optional properties', () => { + expectSnippet(` + const store = new ComponentStore<{ req: string; opt?: number }>({ req: 'a' }); + store.updater((state) => ({ req: 'b' }))(); + `).toSucceed(); + }); + + it('should work with state containing index signature', () => { + expectSnippet(` + const store = new ComponentStore<{ [key: string]: number }>({}); + store.updater((state, v: number) => ({...state, newKey: v}))(5); + `).toSucceed(); + }); + + it('should catch excess properties with concrete state type', () => { + expectSnippet(` + const store = new ComponentStore<{ name: string }>({ name: 'test' }); + store.updater((state, v: string) => ({...state, name: v, extra: true}))('test'); + `).toFail(/Remove excess properties/); + }); + }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 2e2fe12448..7b62beb53e 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -40,6 +40,10 @@ import { import { isOnStateInitDefined, isOnStoreInitDefined } from './lifecycle_hooks'; import { toSignal } from '@angular/core/rxjs-interop'; +const excessPropertiesAreNotAllowedMsg = + 'updater callback return type must exactly match the state type. Remove excess properties.'; +type ExcessPropertiesAreNotAllowed = typeof excessPropertiesAreNotAllowedMsg; + export interface SelectConfig { debounce?: boolean; equal?: ValueEqualityFn; @@ -132,7 +136,17 @@ export class ComponentStore implements OnDestroy { ReturnType = OriginType extends void ? () => void : (observableOrValue: ValueType | Observable) => Subscription, - >(updaterFn: (state: T, value: OriginType) => T): ReturnType { + // Captures the actual return type to enforce exact state shape + R extends T = T, + >( + updaterFn: ( + state: T, + value: OriginType + ) => R & + (Exclude extends never + ? unknown + : ExcessPropertiesAreNotAllowed) + ): ReturnType { return (( observableOrValue?: OriginType | Observable ): Subscription => { @@ -379,9 +393,8 @@ export class ComponentStore implements OnDestroy { // This type quickly became part of effect 'API' ProvidedType = void, // The actual origin$ type, which could be unknown, when not specified - OriginType extends - | Observable - | unknown = Observable, + OriginType extends Observable | unknown = + Observable, // Unwrapped actual type of the origin$ Observable, after default was applied ObservableType = OriginType extends Observable ? A : never, // Return either an optional callback or a function requiring specific types as inputs diff --git a/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts b/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts index f7a8446988..8ab779d5e8 100644 --- a/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts +++ b/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts @@ -12,8 +12,10 @@ export default createRule({ name: path.parse(__filename).name, meta: { type: 'problem', + deprecated: true, docs: { - description: '`Updater` should have an explicit return type.', + description: + '`Updater` should have an explicit return type. Deprecated: `ComponentStore.updater` now enforces exact return types at the type level.', ngrxModule: 'component-store', }, schema: [], diff --git a/projects/www/src/app/pages/guide/component-store/write.md b/projects/www/src/app/pages/guide/component-store/write.md index 2e54125f58..8f61984dd0 100644 --- a/projects/www/src/app/pages/guide/component-store/write.md +++ b/projects/www/src/app/pages/guide/component-store/write.md @@ -46,6 +46,34 @@ export class MoviesStore extends ComponentStore { +The `updater` method enforces that callbacks return an object matching the state type exactly. Returning an object with extra properties that don't exist on the state type will produce a TypeScript compilation error: + + + +```ts +@Injectable() +export class MoviesStore extends ComponentStore { + constructor() { + super({ movies: [] }); + } + + readonly addMovie = this.updater((state, movie: Movie) => ({ + movies: [...state.movies, movie], + // TS error: 'updater()' callback return type must exactly match + // the state type. Remove excess properties. + extra: true, + })); +} +``` + + + + + +**Note:** When `ComponentStore` is extended with a generic state type parameter (e.g., `class MyStore extends ComponentStore`), TypeScript cannot fully resolve the excess property check because `keyof T` is deferred. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as T`) as a workaround. + + + Updater then can be called with the values imperatively or could take an Observable. diff --git a/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md b/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md index 3bc4ee1657..4d813d33fa 100644 --- a/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md +++ b/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md @@ -3,6 +3,7 @@ `Updater` should have an explicit return type. - **Type**: problem +- **Deprecated**: Yes - **Fixable**: No - **Suggestion**: No - **Requires type checking**: No @@ -11,6 +12,10 @@ +## Deprecation Notice + +This rule is deprecated. The `ComponentStore.updater` method now enforces exact return types at the type level, so excess properties in updater callbacks will produce TypeScript compilation errors without needing an explicit return type annotation. It is safe to remove this rule from your ESLint configuration. See the [Updating state guide](guide/component-store/write) for details and known limitations. + ## Rule Details To enforce that the `updater` method from `@ngrx/component-store` returns the expected state interface, we must explicitly add the return type. From 23aa053c3eef6ac59cebe33f0d644ee1aabd4714 Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:08:31 -0500 Subject: [PATCH 2/8] feat(store): enforce exact return types in on() callbacks The on() function now enforces exact return types at the type level, preventing excess properties in callback return values without requiring explicit return type annotations. This eliminates the need for the on-function-explicit-return-type ESLint rule, which is now deprecated. BREAKING CHANGES: Reducer callbacks passed to on() that return objects with properties not present in the state type will now produce TypeScript compilation errors. When on() is used inside a generic reducer factory where the state type is an unresolved generic parameter (e.g. createGenericReducer), callbacks that spread state and override known properties may produce a false type error. Return state directly or use a type assertion (as TState) in those cases. BEFORE: ```ts interface State { name: string; count: number } const initialState: State = { name: '', count: 0 }; const reducer = createReducer( initialState, on(setName, (state, { name }) => ({ ...state, name, extraProp: true, // no error })), ); ``` AFTER: ```ts // Now produces a TypeScript error: // Type '{ extraProp: boolean; ... }' is not assignable to type // '"callback return type must exactly match the state type. // Remove excess properties."' const reducer = createReducer( initialState, on(setName, (state, { name }) => ({ ...state, name, extraProp: true, // TS error })), ); ``` Closes #4280 --- .../store/on-function-explicit-return-type.ts | 4 +- .../store/spec/types/reducer_creator.spec.ts | 216 +++++++++++++++++- modules/store/src/models.ts | 5 + modules/store/src/reducer_creator.ts | 24 +- .../rules/on-function-explicit-return-type.md | 5 + .../www/src/app/pages/guide/store/reducers.md | 27 +++ 6 files changed, 268 insertions(+), 13 deletions(-) diff --git a/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts b/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts index 9eca1e89da..a9ac9c9ce6 100644 --- a/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts +++ b/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts @@ -17,9 +17,11 @@ export default createRule({ name: path.parse(__filename).name, meta: { type: 'suggestion', + deprecated: true, hasSuggestions: true, docs: { - description: '`On` function should have an explicit return type.', + description: + '`On` function should have an explicit return type. Deprecated: The `on` function now enforces exact return types at the type level.', ngrxModule: 'store', }, schema: [], diff --git a/modules/store/spec/types/reducer_creator.spec.ts b/modules/store/spec/types/reducer_creator.spec.ts index 65916355c5..bcb0c208aa 100644 --- a/modules/store/spec/types/reducer_creator.spec.ts +++ b/modules/store/spec/types/reducer_creator.spec.ts @@ -99,9 +99,7 @@ describe('createReducer()', () => { `).toInfer( 'onFn', ` - ReducerTypes<{ - name: string; - }, [ActionCreator<"FOO", (props: { + ReducerTypes { foo: string; @@ -118,11 +116,217 @@ describe('createReducer()', () => { `).toInfer( 'onFn', ` - ReducerTypes<{ - name: string; - }, [ActionCreator<"FOO", () => Action<"FOO">>]> + ReducerTypes Action<"FOO">>]> ` ); }); + + describe('valid patterns', () => { + it('should allow spread with property override inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }) => ({ ...state, name })), + ); + `).toSucceed(); + }); + + it('should allow returning initialState inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const reset = createAction('reset'); + + const reducer = createReducer( + initialState, + on(reset, () => initialState), + ); + `).toSucceed(); + }); + + it('should allow returning state directly inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const noop = createAction('noop'); + + const reducer = createReducer( + initialState, + on(noop, (state) => state), + ); + `).toSucceed(); + }); + + it('should allow standalone on() with explicit state type', () => { + expectSnippet(` + interface State { name: string; count: number }; + const setName = createAction('setName', props<{ name: string }>()); + + const onFn = on(setName, (state: State, { name }) => ({ ...state, name })); + `).toSucceed(); + }); + + it('should allow explicit return of all properties inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }) => ({ name, count: state.count })), + ); + `).toSucceed(); + }); + + it('should allow on() with multiple action creators', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const action1 = createAction('action1'); + const action2 = createAction('action2'); + + const reducer = createReducer( + initialState, + on(action1, action2, (state) => ({ ...state, count: state.count + 1 })), + ); + `).toSucceed(); + }); + }); + + describe('catches excess properties', () => { + it('should catch excess properties in on() callback inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }) => ({ ...state, name, extra: true })), + ); + `).toFail(/Remove excess properties/); + }); + + it('should catch excess properties in standalone on() with explicit state type', () => { + expectSnippet(` + interface State { name: string; count: number }; + const setName = createAction('setName', props<{ name: string }>()); + + const onFn = on(setName, (state: State, { name }) => ({ ...state, name, extra: true })); + `).toFail(/Remove excess properties/); + }); + + it('should catch excess properties when returning explicit object', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }) => ({ name, count: state.count, extra: 'bad' })), + ); + `).toFail(/Remove excess properties/); + }); + + it('should catch excess properties when explicit return type annotation is used', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }): State => ({ ...state, name, extra: true })), + ); + `).toFail(/does not exist in type/); + }); + + it('should catch excess properties from void on() callback', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const noop = createAction('noop'); + + const reducer = createReducer( + initialState, + on(noop, (state) => ({ ...state, extra: true })), + ); + `).toFail(/Remove excess properties/); + }); + }); + + describe('edge cases', () => { + it('should work with state containing optional properties', () => { + expectSnippet(` + interface State { req: string; opt?: number }; + const initialState: State = { req: 'a' }; + const update = createAction('update'); + + const reducer = createReducer( + initialState, + on(update, (state) => ({ req: 'b' })), + ); + `).toSucceed(); + }); + + it('should work with state containing index signature', () => { + expectSnippet(` + const initialState: { [key: string]: number } = {}; + const add = createAction('add', props<{ key: string; value: number }>()); + + const reducer = createReducer( + initialState, + on(add, (state, { key, value }) => ({ ...state, [key]: value })), + ); + `).toSucceed(); + }); + + it('should catch excess properties with index signature state', () => { + expectSnippet(` + interface State { name: string }; + const initialState: State = { name: 'test' }; + const update = createAction('update'); + + const reducer = createReducer( + initialState, + on(update, (state) => ({ ...state, extra: true })), + ); + `).toFail(/Remove excess properties/); + }); + }); + + describe('already enforced type checks', () => { + it('should catch missing required properties inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }) => ({ name })), + ); + `).toFail(/is missing in type/); + }); + + it('should catch wrong property types inside createReducer', () => { + expectSnippet(` + interface State { name: string; count: number }; + const initialState: State = { name: 'test', count: 0 }; + const setName = createAction('setName', props<{ name: string }>()); + + const reducer = createReducer( + initialState, + on(setName, (state, { name }) => ({ ...state, name: 123 })), + ); + `).toFail(/not assignable to type/); + }); + }); }); }, 8_000); diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index a7f75c2dd2..5e3da37993 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -175,4 +175,9 @@ export interface SelectSignalOptions { equal?: ValueEqualityFn; } +export const excessPropertiesAreNotAllowedMsg = + 'callback return type must exactly match the state type. Remove excess properties.'; +export type ExcessPropertiesAreNotAllowed = + typeof excessPropertiesAreNotAllowedMsg; + export type Prettify = { [K in keyof T]: T[K] } & {}; diff --git a/modules/store/src/reducer_creator.ts b/modules/store/src/reducer_creator.ts index a0f4974719..3f3a62f3e1 100644 --- a/modules/store/src/reducer_creator.ts +++ b/modules/store/src/reducer_creator.ts @@ -1,4 +1,10 @@ -import { ActionCreator, ActionReducer, ActionType, Action } from './models'; +import { + ActionCreator, + ActionReducer, + ActionType, + Action, + ExcessPropertiesAreNotAllowed, +} from './models'; // Goes over the array of ActionCreators, pulls the action type out of each one // and returns the array of these action types. @@ -62,14 +68,20 @@ export function on< // is created outside of `createReducer` and state type is either explicitly set OR inferred by return type. // For example: `const onFn = on(action, (state: State, {prop}) => ({ ...state, name: prop }));` InferredState = State, + // Compute the effective state type: either State (when known from createReducer) or InferredState (when standalone) + EffectiveState = unknown extends State ? InferredState : State, + // Captures the actual return type to enforce exact state shape — excess properties produce a descriptive type error + R extends EffectiveState = EffectiveState, >( ...args: [ ...creators: Creators, - reducer: OnReducer< - State extends infer S ? S : never, - Creators, - InferredState - >, + reducer: ( + state: unknown extends State ? InferredState : State, + action: ActionType + ) => R & + (Exclude extends never + ? unknown + : ExcessPropertiesAreNotAllowed), ] ): ReducerTypes { const reducer = args.pop() as unknown as OnReducer< diff --git a/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md b/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md index 6d0e52c30f..c6409d9d81 100644 --- a/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md +++ b/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md @@ -3,6 +3,7 @@ `On` function should have an explicit return type. - **Type**: suggestion +- **Deprecated**: Yes - **Fixable**: No - **Suggestion**: Yes - **Requires type checking**: No @@ -11,6 +12,10 @@ +## Deprecation Notice + +This rule is deprecated. The `on` function now enforces exact return types at the type level, so excess properties in `on` callbacks will produce TypeScript compilation errors without needing an explicit return type annotation. It is safe to remove this rule from your ESLint configuration. See the [Reducers guide](guide/store/reducers) for details and known limitations. + ## Rule Details When we use the `on` function to create reducers, we usually copy the state into a new object, and then add the properties that are being modified after that certain action. This may result in unexpected typing problems, we can add new properties into the state that did not exist previously. TypeScript doesn't see this as a problem and might change the state's interface. The solution is to provide an explicit return type to the `on` function callback. diff --git a/projects/www/src/app/pages/guide/store/reducers.md b/projects/www/src/app/pages/guide/store/reducers.md index 42772b49ca..39026b4fd3 100644 --- a/projects/www/src/app/pages/guide/store/reducers.md +++ b/projects/www/src/app/pages/guide/store/reducers.md @@ -120,6 +120,33 @@ In the example above, the reducer is handling 4 actions: `[Scoreboard Page] Home When an action is dispatched, _all registered reducers_ receive the action. Whether they handle the action is determined by the `on` functions that associate one or more actions with a given state change. +### Exact return type enforcement + +The `on` function enforces that callbacks return an object matching the state type exactly. Returning an object with extra properties that don't exist on the state type will produce a TypeScript compilation error: + + + +```ts +export const scoreboardReducer = createReducer( + initialState, + on(ScoreboardPageActions.homeScore, (state) => ({ + ...state, + home: state.home + 1, + // TS error: 'on()' callback return type must exactly match + // the state type. Remove excess properties. + extra: true, + })) +); +``` + + + + + +**Note:** When `on` is used inside a generic reducer factory where the state type is an unresolved generic parameter (e.g., `function createGenericReducer()`), TypeScript cannot fully resolve the excess property check because `keyof TState` is deferred. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as TState`) as a workaround. + + + **Note:** You can also write reducers using switch statements, which was the previously defined way before reducer creators were introduced in NgRx. If you are looking for examples of reducers using switch statements, visit the documentation for [versions 7.x and prior](https://v7.ngrx.io/guide/store/reducers). From 696698f8dc3d4e3d26ef503596beadabfd0c07c7 Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:48:10 -0500 Subject: [PATCH 3/8] refactor(eslint-plugin): remove explicit return type rules Remove updater-explicit-return-type and on-function-explicit-return-type ESLint rules since TypeScript now enforces exact return types at the type level, making these lint rules unnecessary. Address review feedback by using present tense in docs and simplifying generic type notes. Add v22 migration guide with breaking change callout for rule removal. Co-Authored-By: Claude Opus 4.6 --- .../updater-explicit-return-type.spec.ts | 222 ---------------- .../on-function-explicit-return-type.spec.ts | 242 ------------------ .../src/configs/all-type-checked.json | 2 - .../src/configs/all-type-checked.ts | 2 - modules/eslint-plugin/src/configs/all.json | 2 - modules/eslint-plugin/src/configs/all.ts | 2 - .../src/configs/component-store.json | 3 +- .../src/configs/component-store.ts | 1 - modules/eslint-plugin/src/configs/store.json | 1 - modules/eslint-plugin/src/configs/store.ts | 1 - .../updater-explicit-return-type.ts | 50 ---- modules/eslint-plugin/src/rules/index.ts | 4 - .../store/on-function-explicit-return-type.ts | 81 ------ .../app/pages/guide/component-store/write.md | 4 +- .../app/pages/guide/eslint-plugin/index.md | 2 - .../rules/on-function-explicit-return-type.md | 66 ----- .../rules/updater-explicit-return-type.md | 63 ----- .../www/src/app/pages/guide/migration/v22.md | 49 ++++ .../www/src/app/pages/guide/store/reducers.md | 4 +- .../src/app/services/guide-menu.service.ts | 1 + 20 files changed, 55 insertions(+), 747 deletions(-) delete mode 100644 modules/eslint-plugin/spec/rules/component-store/updater-explicit-return-type.spec.ts delete mode 100644 modules/eslint-plugin/spec/rules/store/on-function-explicit-return-type.spec.ts delete mode 100644 modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts delete mode 100644 modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts delete mode 100644 projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md delete mode 100644 projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md create mode 100644 projects/www/src/app/pages/guide/migration/v22.md diff --git a/modules/eslint-plugin/spec/rules/component-store/updater-explicit-return-type.spec.ts b/modules/eslint-plugin/spec/rules/component-store/updater-explicit-return-type.spec.ts deleted file mode 100644 index 4ee9de511a..0000000000 --- a/modules/eslint-plugin/spec/rules/component-store/updater-explicit-return-type.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { ESLintUtils } from '@typescript-eslint/utils'; -import type { - InvalidTestCase, - ValidTestCase, -} from '@typescript-eslint/rule-tester'; -import * as path from 'path'; -import rule, { - messageId, -} from '../../../src/rules/component-store/updater-explicit-return-type'; -import { ruleTester, fromFixture } from '../../utils'; - -type MessageIds = ESLintUtils.InferMessageIdsTypeFromRule; -type Options = ESLintUtils.InferOptionsTypeFromRule; - -const validConstructor: () => (string | ValidTestCase)[] = () => [ - ` -import { ComponentStore } from '@ngrx/component-store' - -class Ok extends ComponentStore { - readonly addMovie = this.updater( - (state, movie): MoviesState => ({ movies: [...state.movies, movie] }), - ) - - constructor() { - super({ movies: [] }) - } -}`, - ` -import { ComponentStore } from '@ngrx/component-store' - -class Ok1 extends ComponentStore { - readonly addMovie = this.updater( - (state, movie): MoviesState => ({ movies: [...state.movies, movie] }), - ) - - constructor() { - super({ movies: [] }) - } -}`, - ` -import { ComponentStore } from '@ngrx/component-store' - -class Ok2 { - readonly addMovie = this.store.updater( - (state, movie): MoviesState => ({ - movies: [...state.movies, movie], - }), - ) - - constructor(private readonly store: ComponentStore) {} -}`, - ` -import { ComponentStore } from '@ngrx/component-store' - -class Ok3 { - readonly addMovie: Observable - - constructor(customStore: ComponentStore) { - this.addMovie = customStore.updater( - (state, movie): MoviesState => ({ - movies: [...state.movies, movie], - }), - ) - } -}`, -]; - -const validInject: () => (string | ValidTestCase)[] = () => [ - ` -import { ComponentStore } from '@ngrx/component-store' -import { inject } from '@angular/core' - -class Ok4 { - private readonly store = inject(ComponentStore); - readonly addMovie = this.store.updater( - (state, movie): MoviesState => ({ - movies: [...state.movies, movie], - }), - ) -}`, - ` -import { ComponentStore } from '@ngrx/component-store' -import { inject } from '@angular/core' - -class Ok5 { - readonly addMovie: Observable - customStore = inject(ComponentStore) - - constructor() { - this.addMovie = this.customStore.updater( - (state, movie): MoviesState => ({ - movies: [...state.movies, movie], - }), - ) - } -}`, -]; - -const invalidConstructor: () => InvalidTestCase[] = () => [ - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' - -class NotOk extends ComponentStore { - readonly addMovie = this.updater((state, movie) => ({ movies: [...state.movies, movie] })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - - constructor() { - super({ movies: [] }) - } -}`), - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' - -class NotOk1 extends ComponentStore { - readonly updateMovie: Observable - readonly addMovie = this.updater((state, movie) => movie ? ({ movies: [...state.movies, movie] }) : ({ movies })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - - constructor(componentStore: ComponentStore) { - super({ movies: [] }) - this.updateMovie = componentStore.updater(() => ({ movies: MOVIES })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - } -}`), - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' - -class NotOk2 { - readonly addMovie = this.store.updater((state, movie) => ({ movies: [...state.movies, movie] })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - - constructor(private readonly store: ComponentStore) {} -}`), - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' - -class NotOk3 { - readonly addMovie: Observable - readonly updateMovie: Observable - - constructor( - customStore: ComponentStore, - private readonly store: ComponentStore - ) { - this.addMovie = customStore.updater((state, movie) => ({ movies: [...state.movies, movie] })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - this.updateMovie = this.store.updater(() => ({ movies: MOVIES })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - } - - ngOnInit() { - const updater = (item: Movie) => item - updater() - } -}`), - fromFixture(` -@Injectable() -export class CompetitorsStore2 extends CompetitorsStore1 { - override updateName = this.updater((state, name: string) => ({ ...state, name })); - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - - - updateName2 = this.updater(() => ({ name: 'test' })); - ~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] -}`), -]; - -const invalidInject: () => InvalidTestCase[] = () => [ - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' -import { inject } from '@angular/core' - -class NotOk4 { - componentStore = inject(ComponentStore) - readonly updateMovie: Observable - - constructor() { - this.updateMovie = this.componentStore.updater(() => ({ movies: MOVIES })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - } -}`), - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' -import { inject } from '@angular/core' - -class NotOk5 { - private readonly store = inject(ComponentStore) - readonly addMovie = this.store.updater((state, movie) => ({ movies: [...state.movies, movie] })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] -}`), - fromFixture(` -import { ComponentStore } from '@ngrx/component-store' -import { inject } from '@angular/core' - -class NotOk6 { - customStore = inject(ComponentStore) - private readonly store = inject(ComponentStore) - readonly addMovie: Observable - readonly updateMovie: Observable - - constructor() { - this.addMovie = this.customStore.updater((state, movie) => ({ movies: [...state.movies, movie] })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - this.updateMovie = this.store.updater(() => ({ movies: MOVIES })) - ~~~~~~~~~~~~~~~~~~~~~~~~~~ [${messageId}] - } - - ngOnInit() { - const updater = (item: Movie) => item - updater() - } -}`), -]; - -ruleTester(rule.meta.docs?.requiresTypeChecking).run( - path.parse(__filename).name, - rule, - { - valid: [...validConstructor(), ...validInject()], - invalid: [...invalidConstructor(), ...invalidInject()], - } -); diff --git a/modules/eslint-plugin/spec/rules/store/on-function-explicit-return-type.spec.ts b/modules/eslint-plugin/spec/rules/store/on-function-explicit-return-type.spec.ts deleted file mode 100644 index f261c51602..0000000000 --- a/modules/eslint-plugin/spec/rules/store/on-function-explicit-return-type.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type { ESLintUtils } from '@typescript-eslint/utils'; -import type { - InvalidTestCase, - ValidTestCase, -} from '@typescript-eslint/rule-tester'; -import * as path from 'path'; -import rule, { - onFunctionExplicitReturnType, - onFunctionExplicitReturnTypeSuggest, -} from '../../../src/rules/store/on-function-explicit-return-type'; -import { ruleTester } from '../../utils'; - -type MessageIds = ESLintUtils.InferMessageIdsTypeFromRule; -type Options = ESLintUtils.InferOptionsTypeFromRule; - -const valid: () => (string | ValidTestCase)[] = () => [ - ` -const reducer = createReducer( - initialState, - on( - increment, - (s): State => ({ - ...s, - counter: s.counter + 1, - }), - ), -)`, - ` -const reducer = createReducer( - initialState, - on(increment, incrementFunc), - on(increment, (s): State => incrementFunc(s)), -)`, - ` -const reducer = createReducer( - initialState, - on( - increment, - produce((draft: State, action) => { - draft.counter++; - }), - ), -)`, - // https://github.com/timdeschryver/ngrx-tslint-rules/pull/37 - ` -const reducer = createReducer( - on(increment, (s): State => ({ - ...s, - counter: (s => s.counter + 1)(s), - })), -)`, -]; - -const invalid: () => InvalidTestCase[] = () => [ - { - code: ` -const reducer = createReducer( - initialState, - on(increment, s => s), -)`, - errors: [ - { - column: 17, - endColumn: 23, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(increment, (s): State => s), -)`, - }, - ], - }, - ], - }, - { - code: ` -const reducer = createReducer( - initialState, - on(increment, s => ({ ...s, counter: s.counter + 1 })), -)`, - errors: [ - { - column: 17, - endColumn: 56, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(increment, (s): State => ({ ...s, counter: s.counter + 1 })), -)`, - }, - ], - }, - ], - }, - { - code: ` -const reducer = createReducer( - initialState, - on(increase, (s, action) => ({ ...s, counter: s.counter + action.value })), -)`, - errors: [ - { - column: 16, - endColumn: 76, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(increase, (s, action): State => ({ ...s, counter: s.counter + action.value })), -)`, - }, - ], - }, - ], - }, - - { - code: ` -const reducer = createReducer( - initialState, - on(increase, (s, { value }) => ( { ...s, counter: s.counter + value } ) ), -)`, - errors: [ - { - column: 16, - endColumn: 81, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(increase, (s, { value }): State => ( { ...s, counter: s.counter + value } ) ), -)`, - }, - ], - }, - ], - }, - - { - code: ` -const reducer = createReducer( - initialState, - on(reset, () => initialState ), -)`, - errors: [ - { - column: 13, - endColumn: 33, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(reset, (): State => initialState ), -)`, - }, - ], - }, - ], - }, - { - code: ` -const reducer = createReducer( - initialState, - on(reset, s => foo(s)), -)`, - errors: [ - { - column: 13, - endColumn: 24, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(reset, (s): State => foo(s)), -)`, - }, - ], - }, - ], - }, - // https://github.com/ngrx/platform/issues/4901 - { - code: ` -const reducer = createReducer( - initialState, - on(reset, s => ({ ...s, counter: Number(1) })), -)`, - errors: [ - { - column: 13, - endColumn: 48, - line: 4, - messageId: onFunctionExplicitReturnType, - suggestions: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - output: ` -const reducer = createReducer( - initialState, - on(reset, (s): State => ({ ...s, counter: Number(1) })), -)`, - }, - ], - }, - ], - }, -]; - -ruleTester(rule.meta.docs?.requiresTypeChecking).run( - path.parse(__filename).name, - rule, - { - valid: valid(), - invalid: invalid(), - } -); diff --git a/modules/eslint-plugin/src/configs/all-type-checked.json b/modules/eslint-plugin/src/configs/all-type-checked.json index 62cf71345c..ddd7dce7ea 100644 --- a/modules/eslint-plugin/src/configs/all-type-checked.json +++ b/modules/eslint-plugin/src/configs/all-type-checked.json @@ -5,7 +5,6 @@ "@ngrx/avoid-combining-component-store-selectors": "error", "@ngrx/avoid-mapping-component-store-selectors": "error", "@ngrx/require-super-ondestroy": "error", - "@ngrx/updater-explicit-return-type": "error", "@ngrx/avoid-cyclic-effects": "error", "@ngrx/no-dispatch-in-effects": "error", "@ngrx/no-effects-in-providers": "error", @@ -28,7 +27,6 @@ "@ngrx/no-reducer-in-key-names": "error", "@ngrx/no-store-subscription": "error", "@ngrx/no-typed-global-store": "error", - "@ngrx/on-function-explicit-return-type": "error", "@ngrx/prefer-action-creator-in-dispatch": "error", "@ngrx/prefer-action-creator": "error", "@ngrx/prefer-inline-action-props": "error", diff --git a/modules/eslint-plugin/src/configs/all-type-checked.ts b/modules/eslint-plugin/src/configs/all-type-checked.ts index 9c9355537f..d12db84efe 100644 --- a/modules/eslint-plugin/src/configs/all-type-checked.ts +++ b/modules/eslint-plugin/src/configs/all-type-checked.ts @@ -27,7 +27,6 @@ export default ( '@ngrx/avoid-combining-component-store-selectors': 'error', '@ngrx/avoid-mapping-component-store-selectors': 'error', '@ngrx/require-super-ondestroy': 'error', - '@ngrx/updater-explicit-return-type': 'error', '@ngrx/avoid-cyclic-effects': 'error', '@ngrx/no-dispatch-in-effects': 'error', '@ngrx/no-effects-in-providers': 'error', @@ -50,7 +49,6 @@ export default ( '@ngrx/no-reducer-in-key-names': 'error', '@ngrx/no-store-subscription': 'error', '@ngrx/no-typed-global-store': 'error', - '@ngrx/on-function-explicit-return-type': 'error', '@ngrx/prefer-action-creator-in-dispatch': 'error', '@ngrx/prefer-action-creator': 'error', '@ngrx/prefer-inline-action-props': 'error', diff --git a/modules/eslint-plugin/src/configs/all.json b/modules/eslint-plugin/src/configs/all.json index 9256b8ab10..1bf93cd11e 100644 --- a/modules/eslint-plugin/src/configs/all.json +++ b/modules/eslint-plugin/src/configs/all.json @@ -5,7 +5,6 @@ "@ngrx/avoid-combining-component-store-selectors": "error", "@ngrx/avoid-mapping-component-store-selectors": "error", "@ngrx/require-super-ondestroy": "error", - "@ngrx/updater-explicit-return-type": "error", "@ngrx/no-dispatch-in-effects": "error", "@ngrx/no-effects-in-providers": "error", "@ngrx/prefer-action-creator-in-of-type": "error", @@ -24,7 +23,6 @@ "@ngrx/no-reducer-in-key-names": "error", "@ngrx/no-store-subscription": "error", "@ngrx/no-typed-global-store": "error", - "@ngrx/on-function-explicit-return-type": "error", "@ngrx/prefer-action-creator-in-dispatch": "error", "@ngrx/prefer-action-creator": "error", "@ngrx/prefer-inline-action-props": "error", diff --git a/modules/eslint-plugin/src/configs/all.ts b/modules/eslint-plugin/src/configs/all.ts index 1c34b57d30..934d1c14cf 100644 --- a/modules/eslint-plugin/src/configs/all.ts +++ b/modules/eslint-plugin/src/configs/all.ts @@ -27,7 +27,6 @@ export default ( '@ngrx/avoid-combining-component-store-selectors': 'error', '@ngrx/avoid-mapping-component-store-selectors': 'error', '@ngrx/require-super-ondestroy': 'error', - '@ngrx/updater-explicit-return-type': 'error', '@ngrx/no-dispatch-in-effects': 'error', '@ngrx/no-effects-in-providers': 'error', '@ngrx/prefer-action-creator-in-of-type': 'error', @@ -46,7 +45,6 @@ export default ( '@ngrx/no-reducer-in-key-names': 'error', '@ngrx/no-store-subscription': 'error', '@ngrx/no-typed-global-store': 'error', - '@ngrx/on-function-explicit-return-type': 'error', '@ngrx/prefer-action-creator-in-dispatch': 'error', '@ngrx/prefer-action-creator': 'error', '@ngrx/prefer-inline-action-props': 'error', diff --git a/modules/eslint-plugin/src/configs/component-store.json b/modules/eslint-plugin/src/configs/component-store.json index d1ae489cf9..f400846d62 100644 --- a/modules/eslint-plugin/src/configs/component-store.json +++ b/modules/eslint-plugin/src/configs/component-store.json @@ -4,7 +4,6 @@ "rules": { "@ngrx/avoid-combining-component-store-selectors": "error", "@ngrx/avoid-mapping-component-store-selectors": "error", - "@ngrx/require-super-ondestroy": "error", - "@ngrx/updater-explicit-return-type": "error" + "@ngrx/require-super-ondestroy": "error" } } diff --git a/modules/eslint-plugin/src/configs/component-store.ts b/modules/eslint-plugin/src/configs/component-store.ts index d233093879..2a9fbe0da7 100644 --- a/modules/eslint-plugin/src/configs/component-store.ts +++ b/modules/eslint-plugin/src/configs/component-store.ts @@ -27,7 +27,6 @@ export default ( '@ngrx/avoid-combining-component-store-selectors': 'error', '@ngrx/avoid-mapping-component-store-selectors': 'error', '@ngrx/require-super-ondestroy': 'error', - '@ngrx/updater-explicit-return-type': 'error', }, }, ]; diff --git a/modules/eslint-plugin/src/configs/store.json b/modules/eslint-plugin/src/configs/store.json index f0d1dd0438..857b720904 100644 --- a/modules/eslint-plugin/src/configs/store.json +++ b/modules/eslint-plugin/src/configs/store.json @@ -11,7 +11,6 @@ "@ngrx/no-reducer-in-key-names": "error", "@ngrx/no-store-subscription": "error", "@ngrx/no-typed-global-store": "error", - "@ngrx/on-function-explicit-return-type": "error", "@ngrx/prefer-action-creator-in-dispatch": "error", "@ngrx/prefer-action-creator": "error", "@ngrx/prefer-inline-action-props": "error", diff --git a/modules/eslint-plugin/src/configs/store.ts b/modules/eslint-plugin/src/configs/store.ts index 7c0f2c89ce..9004d78df6 100644 --- a/modules/eslint-plugin/src/configs/store.ts +++ b/modules/eslint-plugin/src/configs/store.ts @@ -33,7 +33,6 @@ export default ( '@ngrx/no-reducer-in-key-names': 'error', '@ngrx/no-store-subscription': 'error', '@ngrx/no-typed-global-store': 'error', - '@ngrx/on-function-explicit-return-type': 'error', '@ngrx/prefer-action-creator-in-dispatch': 'error', '@ngrx/prefer-action-creator': 'error', '@ngrx/prefer-inline-action-props': 'error', diff --git a/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts b/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts deleted file mode 100644 index 8ab779d5e8..0000000000 --- a/modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { TSESTree } from '@typescript-eslint/utils'; -import * as path from 'path'; -import { createRule } from '../../rule-creator'; -import { getNgrxComponentStoreNames, namedExpression } from '../../utils'; - -export const messageId = 'updaterExplicitReturnType'; - -type MessageIds = typeof messageId; -type Options = readonly []; - -export default createRule({ - name: path.parse(__filename).name, - meta: { - type: 'problem', - deprecated: true, - docs: { - description: - '`Updater` should have an explicit return type. Deprecated: `ComponentStore.updater` now enforces exact return types at the type level.', - ngrxModule: 'component-store', - }, - schema: [], - messages: { - [messageId]: - '`Updater` should have an explicit return type when using arrow functions: `this.store.updater((state, value): State => {}`.', - }, - }, - defaultOptions: [], - create: (context) => { - const storeNames = getNgrxComponentStoreNames(context); - const withoutTypeAnnotation = `ArrowFunctionExpression:not([returnType.typeAnnotation])`; - const selectors = [ - `ClassDeclaration[superClass.name=/Store/] CallExpression[callee.object.type='ThisExpression'][callee.property.name='updater'] > ${withoutTypeAnnotation}`, - storeNames && - `${namedExpression( - storeNames - )}[callee.property.name='updater'] > ${withoutTypeAnnotation}`, - ] - .filter(Boolean) - .join(','); - - return { - [selectors](node: TSESTree.ArrowFunctionExpression) { - context.report({ - node, - messageId, - }); - }, - }; - }, -}); diff --git a/modules/eslint-plugin/src/rules/index.ts b/modules/eslint-plugin/src/rules/index.ts index adc6f88979..1874e74ae1 100644 --- a/modules/eslint-plugin/src/rules/index.ts +++ b/modules/eslint-plugin/src/rules/index.ts @@ -1,7 +1,6 @@ // component-store import avoidCombiningComponentStoreSelectors from './component-store/avoid-combining-component-store-selectors'; import avoidMappingComponentStoreSelectors from './component-store/avoid-mapping-component-store-selectors'; -import updaterExplicitReturnType from './component-store/updater-explicit-return-type'; import requireSuperOnDestroy from './component-store/require-super-ondestroy'; // effects import avoidCyclicEffects from './effects/avoid-cyclic-effects'; @@ -21,7 +20,6 @@ import noMultipleGlobalStores from './store/no-multiple-global-stores'; import noReducerInKeyNames from './store/no-reducer-in-key-names'; import noStoreSubscription from './store/no-store-subscription'; import noTypedGlobalStore from './store/no-typed-global-store'; -import onFunctionExplicitReturnType from './store/on-function-explicit-return-type'; import preferActionCreator from './store/prefer-action-creator'; import preferActionCreatorInDispatch from './store/prefer-action-creator-in-dispatch'; import preferInlineActionProps from './store/prefer-inline-action-props'; @@ -45,7 +43,6 @@ export const rules = { avoidCombiningComponentStoreSelectors, 'avoid-mapping-component-store-selectors': avoidMappingComponentStoreSelectors, - 'updater-explicit-return-type': updaterExplicitReturnType, 'require-super-ondestroy': requireSuperOnDestroy, //effects 'avoid-cyclic-effects': avoidCyclicEffects, @@ -67,7 +64,6 @@ export const rules = { 'no-reducer-in-key-names': noReducerInKeyNames, 'no-store-subscription': noStoreSubscription, 'no-typed-global-store': noTypedGlobalStore, - 'on-function-explicit-return-type': onFunctionExplicitReturnType, 'prefer-action-creator': preferActionCreator, 'prefer-action-creator-in-dispatch': preferActionCreatorInDispatch, 'prefer-inline-action-props': preferInlineActionProps, diff --git a/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts b/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts deleted file mode 100644 index a9ac9c9ce6..0000000000 --- a/modules/eslint-plugin/src/rules/store/on-function-explicit-return-type.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; -import { ASTUtils } from '@typescript-eslint/utils'; -import * as path from 'path'; -import { createRule } from '../../rule-creator'; -import { getLast, onFunctionWithoutType } from '../../utils'; - -export const onFunctionExplicitReturnType = 'onFunctionExplicitReturnType'; -export const onFunctionExplicitReturnTypeSuggest = - 'onFunctionExplicitReturnTypeSuggest'; - -type MessageIds = - | typeof onFunctionExplicitReturnType - | typeof onFunctionExplicitReturnTypeSuggest; -type Options = readonly []; - -export default createRule({ - name: path.parse(__filename).name, - meta: { - type: 'suggestion', - deprecated: true, - hasSuggestions: true, - docs: { - description: - '`On` function should have an explicit return type. Deprecated: The `on` function now enforces exact return types at the type level.', - ngrxModule: 'store', - }, - schema: [], - messages: { - [onFunctionExplicitReturnType]: - '`On` functions should have an explicit return type when using arrow functions: `on(action, (state): State => {}`.', - [onFunctionExplicitReturnTypeSuggest]: - 'Add the explicit return type `State` (if the interface/type is named differently you need to manually correct the return type).', - }, - }, - defaultOptions: [], - create: (context) => { - return { - [onFunctionWithoutType](node: TSESTree.ArrowFunctionExpression) { - context.report({ - node, - messageId: onFunctionExplicitReturnType, - suggest: [ - { - messageId: onFunctionExplicitReturnTypeSuggest, - fix: (fixer) => getFixes(node, context.sourceCode, fixer), - }, - ], - }); - }, - }; - }, -}); - -function getFixes( - node: TSESTree.ArrowFunctionExpression, - sourceCode: Readonly, - fixer: TSESLint.RuleFixer -) { - const { params } = node; - - if (params.length === 0) { - const [, closingParen] = sourceCode.getTokens(node); - return fixer.insertTextAfter(closingParen, ': State'); - } - - const [firstParam] = params; - const lastParam = getLast(params); - const previousToken = sourceCode.getTokenBefore(firstParam); - const isParenthesized = - previousToken && ASTUtils.isOpeningParenToken(previousToken); - - if (isParenthesized) { - const nextToken = sourceCode.getTokenAfter(lastParam); - return fixer.insertTextAfter(nextToken ?? lastParam, ': State'); - } - - return [ - fixer.insertTextBefore(firstParam, '('), - fixer.insertTextAfter(lastParam, '): State'), - ] as const; -} diff --git a/projects/www/src/app/pages/guide/component-store/write.md b/projects/www/src/app/pages/guide/component-store/write.md index 8f61984dd0..a8c591e9ec 100644 --- a/projects/www/src/app/pages/guide/component-store/write.md +++ b/projects/www/src/app/pages/guide/component-store/write.md @@ -46,7 +46,7 @@ export class MoviesStore extends ComponentStore { -The `updater` method enforces that callbacks return an object matching the state type exactly. Returning an object with extra properties that don't exist on the state type will produce a TypeScript compilation error: +The `updater` method enforces that callbacks return an object matching the state type exactly. Returning an object with extra properties that don't exist on the state type produces a TypeScript compilation error: @@ -70,7 +70,7 @@ export class MoviesStore extends ComponentStore { -**Note:** When `ComponentStore` is extended with a generic state type parameter (e.g., `class MyStore extends ComponentStore`), TypeScript cannot fully resolve the excess property check because `keyof T` is deferred. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as T`) as a workaround. +**Note:** When `ComponentStore` is extended with a generic state type parameter (e.g., `class MyStore extends ComponentStore`), TypeScript cannot fully resolve the excess property check. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as T`) as a workaround. diff --git a/projects/www/src/app/pages/guide/eslint-plugin/index.md b/projects/www/src/app/pages/guide/eslint-plugin/index.md index 0f417f5394..dcee5b0cf8 100644 --- a/projects/www/src/app/pages/guide/eslint-plugin/index.md +++ b/projects/www/src/app/pages/guide/eslint-plugin/index.md @@ -134,7 +134,6 @@ This is useful if you only use a specific package, as it only includes the rules | [@ngrx/avoid-combining-component-store-selectors](/guide/eslint-plugin/rules/avoid-combining-component-store-selectors) | Prefer combining selectors at the selector level. | suggestion | No | No | No | No | | [@ngrx/avoid-mapping-component-store-selectors](/guide/eslint-plugin/rules/avoid-mapping-component-store-selectors) | Avoid mapping logic outside the selector level. | problem | No | No | No | No | | [@ngrx/require-super-ondestroy](/guide/eslint-plugin/rules/require-super-ondestroy) | Overriden ngOnDestroy method in component stores require a call to super.ngOnDestroy(). | problem | No | No | No | No | -| [@ngrx/updater-explicit-return-type](/guide/eslint-plugin/rules/updater-explicit-return-type) | `Updater` should have an explicit return type. | problem | No | No | No | No | ### effects @@ -177,7 +176,6 @@ This is useful if you only use a specific package, as it only includes the rules | [@ngrx/no-reducer-in-key-names](/guide/eslint-plugin/rules/no-reducer-in-key-names) | Avoid the word "reducer" in the key names. | suggestion | No | Yes | No | No | | [@ngrx/no-store-subscription](/guide/eslint-plugin/rules/no-store-subscription) | Using the `async` pipe is preferred over `store` subscription. | suggestion | No | No | No | No | | [@ngrx/no-typed-global-store](/guide/eslint-plugin/rules/no-typed-global-store) | The global store should not be typed. | suggestion | No | Yes | No | No | -| [@ngrx/on-function-explicit-return-type](/guide/eslint-plugin/rules/on-function-explicit-return-type) | `On` function should have an explicit return type. | suggestion | No | Yes | No | No | | [@ngrx/prefer-action-creator-in-dispatch](/guide/eslint-plugin/rules/prefer-action-creator-in-dispatch) | Using `action creator` in `dispatch` is preferred over `object` or old `Action`. | suggestion | No | No | No | No | | [@ngrx/prefer-action-creator](/guide/eslint-plugin/rules/prefer-action-creator) | Using `action creator` is preferred over `Action class`. | suggestion | No | No | No | No | | [@ngrx/prefer-inline-action-props](/guide/eslint-plugin/rules/prefer-inline-action-props) | Prefer using inline types instead of interfaces, types or classes. | suggestion | No | Yes | No | No | diff --git a/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md b/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md deleted file mode 100644 index c6409d9d81..0000000000 --- a/projects/www/src/app/pages/guide/eslint-plugin/rules/on-function-explicit-return-type.md +++ /dev/null @@ -1,66 +0,0 @@ -# on-function-explicit-return-type - -`On` function should have an explicit return type. - -- **Type**: suggestion -- **Deprecated**: Yes -- **Fixable**: No -- **Suggestion**: Yes -- **Requires type checking**: No -- **Configurable**: No - - - - -## Deprecation Notice - -This rule is deprecated. The `on` function now enforces exact return types at the type level, so excess properties in `on` callbacks will produce TypeScript compilation errors without needing an explicit return type annotation. It is safe to remove this rule from your ESLint configuration. See the [Reducers guide](guide/store/reducers) for details and known limitations. - -## Rule Details - -When we use the `on` function to create reducers, we usually copy the state into a new object, and then add the properties that are being modified after that certain action. This may result in unexpected typing problems, we can add new properties into the state that did not exist previously. TypeScript doesn't see this as a problem and might change the state's interface. The solution is to provide an explicit return type to the `on` function callback. - -Examples of **incorrect** code for this rule: - - - -```ts -export interface AppState { - username: string; -} - -const reducer = createReducer( - { username: '' }, - on(setUsername, (state, action) => ({ - ...state, - username: action.payload, - newProperty: 1, // we added a property that does not exist on `AppState`, and TS won't catch this problem - })) -); -``` - - - -Examples of **correct** code for this rule: - - - -```ts -export interface AppState { - username: string; -} - -const reducer = createReducer( - { username: '' }, - on( - setUsername, - (state, action): AppState => ({ - ...state, - username: action.payload, - // adding new properties that do not exist on `AppState` is impossible, as the function return type is explicitly stated - }) - ) -); -``` - - diff --git a/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md b/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md deleted file mode 100644 index 4d813d33fa..0000000000 --- a/projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md +++ /dev/null @@ -1,63 +0,0 @@ -# updater-explicit-return-type - -`Updater` should have an explicit return type. - -- **Type**: problem -- **Deprecated**: Yes -- **Fixable**: No -- **Suggestion**: No -- **Requires type checking**: No -- **Configurable**: No - - - - -## Deprecation Notice - -This rule is deprecated. The `ComponentStore.updater` method now enforces exact return types at the type level, so excess properties in updater callbacks will produce TypeScript compilation errors without needing an explicit return type annotation. It is safe to remove this rule from your ESLint configuration. See the [Updating state guide](guide/component-store/write) for details and known limitations. - -## Rule Details - -To enforce that the `updater` method from `@ngrx/component-store` returns the expected state interface, we must explicitly add the return type. - -Examples of **incorrect** code for this rule: - - - -```ts -interface MoviesState { - movies: Movie[]; -} - -class MoviesStore extends ComponentStore { - readonly addMovie = this.updater((state, movie: Movie) => ({ - movies: [...state.movies, movie], - // ⚠ this doesn't throw, but is caught by the linter - extra: 'property', - })); -} -``` - - - -Examples of **correct** code for this rule: - - - -```ts -interface MoviesState { - movies: Movie[]; -} - -class MoviesStore extends ComponentStore { - readonly addMovie = this.updater( - (state, movie: Movie): MoviesState => ({ - movies: [...state.movies, movie], - // ⚠ this does throw - extra: 'property', - }) - ); -} -``` - - diff --git a/projects/www/src/app/pages/guide/migration/v22.md b/projects/www/src/app/pages/guide/migration/v22.md new file mode 100644 index 0000000000..e825ef7779 --- /dev/null +++ b/projects/www/src/app/pages/guide/migration/v22.md @@ -0,0 +1,49 @@ +# V22 Update Guide + +## Angular CLI update + +NgRx supports using the Angular CLI `ng update` command to update your dependencies. Migration schematics are run to make the upgrade smoother. These schematics will fix some of the breaking changes. + +To update your packages to the latest released version, run the command below. + +```sh +ng update @ngrx/store@22 +``` + +## Dependencies + +Version 22 has the minimum version requirements: + +- Angular version 22 +- Angular CLI version 22 +- TypeScript version 5.9 +- RxJS version ^6.5.x || ^7.5.0 + +## Breaking changes + +### @ngrx/eslint-plugin + +#### `updater-explicit-return-type` and `on-function-explicit-return-type` rules removed + +The `@ngrx/updater-explicit-return-type` and `@ngrx/on-function-explicit-return-type` ESLint rules have been removed. The `on()` and `updater()` functions now enforce exact return types at the TypeScript level, making these lint rules unnecessary. + +Remove these rules from your ESLint configuration: + +BEFORE: + +```json +{ + "rules": { + "@ngrx/on-function-explicit-return-type": "error", + "@ngrx/updater-explicit-return-type": "error" + } +} +``` + +AFTER: + +```json +{ + "rules": {} +} +``` diff --git a/projects/www/src/app/pages/guide/store/reducers.md b/projects/www/src/app/pages/guide/store/reducers.md index 39026b4fd3..d3a3160475 100644 --- a/projects/www/src/app/pages/guide/store/reducers.md +++ b/projects/www/src/app/pages/guide/store/reducers.md @@ -122,7 +122,7 @@ When an action is dispatched, _all registered reducers_ receive the action. Whet ### Exact return type enforcement -The `on` function enforces that callbacks return an object matching the state type exactly. Returning an object with extra properties that don't exist on the state type will produce a TypeScript compilation error: +The `on` function enforces that callbacks return an object matching the state type exactly. Returning an object with extra properties that don't exist on the state type produces a TypeScript compilation error: @@ -143,7 +143,7 @@ export const scoreboardReducer = createReducer( -**Note:** When `on` is used inside a generic reducer factory where the state type is an unresolved generic parameter (e.g., `function createGenericReducer()`), TypeScript cannot fully resolve the excess property check because `keyof TState` is deferred. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as TState`) as a workaround. +**Note:** When `on` is used inside a generic reducer factory where the state type is an unresolved generic parameter (e.g., `function createGenericReducer()`), TypeScript cannot fully resolve the excess property check. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as TState`) as a workaround. diff --git a/projects/www/src/app/services/guide-menu.service.ts b/projects/www/src/app/services/guide-menu.service.ts index 1147fe33e0..aff40b590d 100644 --- a/projects/www/src/app/services/guide-menu.service.ts +++ b/projects/www/src/app/services/guide-menu.service.ts @@ -199,6 +199,7 @@ export class GuideMenuService { section('Developer Resources', [ link('Nightlies', '/guide/nightlies'), section('Migrations', [ + link('V22', '/guide/migration/v22'), link('V21', '/guide/migration/v21'), link('V20', '/guide/migration/v20'), link('V19', '/guide/migration/v19'), From 15039a87bdf83fa2d4be2422f26f4d593a9bfdd6 Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:00:52 -0400 Subject: [PATCH 4/8] test(component-store): convert updater type tests to Vitest expectTypeOf The updater exact-return-type tests added in this branch used ts-snippet's expectSnippet API, which was removed when #5145 migrated the component-store type specs to Vitest. Rewrite them with expectTypeOf / @ts-expect-error to match the migrated specs and fix the test-affected CI failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spec/types/component-store.types.spec.ts | 103 +++++++++++++----- .../spec/types/regression.types.spec.ts | 25 +++-- 2 files changed, 89 insertions(+), 39 deletions(-) diff --git a/modules/component-store/spec/types/component-store.types.spec.ts b/modules/component-store/spec/types/component-store.types.spec.ts index ac66cd0ed4..86d8527d49 100644 --- a/modules/component-store/spec/types/component-store.types.spec.ts +++ b/modules/component-store/spec/types/component-store.types.spec.ts @@ -348,57 +348,106 @@ describe('ComponentStore types', () => { describe('catches excess properties', () => { it('when extra property is returned with spread', () => { - expectSnippet( - `componentStore.updater((state, v: string) => ({...state, extraProp: 'bad'}))('test');` - ).toFail(/Remove excess properties/); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + componentStore.updater((state, v: string) => ({ + ...state, + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + extraProp: 'bad', + }))('test'); }); it('when extra property is returned with explicit object', () => { - expectSnippet( - `componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2, extraProp: 'bad' }))('test');` - ).toFail(/Remove excess properties/); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + componentStore.updater((state, v: string) => ({ + prop: v, + prop2: state.prop2, + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + extraProp: 'bad', + }))('test'); }); it('when extra property is returned from void updater', () => { - expectSnippet( - `componentStore.updater((state) => ({...state, extraProp: true}))();` - ).toFail(/Remove excess properties/); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + componentStore.updater((state) => ({ + ...state, + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + extraProp: true, + }))(); }); it('when required property is missing', () => { - expectSnippet( - `componentStore.updater((state, v: string) => ({ prop: v }))('test');` - ).toFail(/is missing in type/); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + // @ts-expect-error Property 'prop2' is missing in type '{ prop: string; }' + componentStore.updater((state, v: string) => ({ prop: v }))('test'); }); it('when property has wrong type', () => { - expectSnippet( - `componentStore.updater((state, v: string) => ({...state, prop: 123}))('test');` - ).toFail(/not assignable to type/); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + componentStore.updater((state, v: string) => ({ + ...state, + // @ts-expect-error Type 'number' is not assignable to type 'string' + prop: 123, + }))('test'); }); it('allows spread with override', () => { - expectSnippet( - `const sub = componentStore.updater((state, v: string) => ({...state, prop: v}))('test');` - ).toInfer('sub', 'Subscription'); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + const sub = componentStore.updater((state, v: string) => ({ + ...state, + prop: v, + }))('test'); + expectTypeOf(sub).toEqualTypeOf(); }); it('allows full explicit return matching all state keys', () => { - expectSnippet( - `const sub = componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2 }))('test');` - ).toInfer('sub', 'Subscription'); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + const sub = componentStore.updater((state, v: string) => ({ + prop: v, + prop2: state.prop2, + }))('test'); + expectTypeOf(sub).toEqualTypeOf(); }); it('allows void updater with spread return', () => { - expectSnippet( - `const v = componentStore.updater((state) => ({...state, prop: 'updated'}))();` - ).toInfer('v', 'void'); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + const v = componentStore.updater((state) => ({ + ...state, + prop: 'updated', + }))(); + expectTypeOf(v).toBeVoid(); }); it('allows direct state return', () => { - expectSnippet( - `const v = componentStore.updater((state) => state)();` - ).toInfer('v', 'void'); + const componentStore = new ComponentStore({ + prop: 'init', + prop2: 'yeah!', + }); + const v = componentStore.updater((state) => state)(); + expectTypeOf(v).toBeVoid(); }); }); }); diff --git a/modules/component-store/spec/types/regression.types.spec.ts b/modules/component-store/spec/types/regression.types.spec.ts index 2245bc6390..558a4174f1 100644 --- a/modules/component-store/spec/types/regression.types.spec.ts +++ b/modules/component-store/spec/types/regression.types.spec.ts @@ -33,24 +33,25 @@ describe('regression component-store', () => { describe('updater exact return type', () => { it('should work with state containing optional properties', () => { - expectSnippet(` - const store = new ComponentStore<{ req: string; opt?: number }>({ req: 'a' }); - store.updater((state) => ({ req: 'b' }))(); - `).toSucceed(); + const store = new ComponentStore<{ req: string; opt?: number }>({ + req: 'a', + }); + store.updater((state) => ({ req: 'b' }))(); }); it('should work with state containing index signature', () => { - expectSnippet(` - const store = new ComponentStore<{ [key: string]: number }>({}); - store.updater((state, v: number) => ({...state, newKey: v}))(5); - `).toSucceed(); + const store = new ComponentStore<{ [key: string]: number }>({}); + store.updater((state, v: number) => ({ ...state, newKey: v }))(5); }); it('should catch excess properties with concrete state type', () => { - expectSnippet(` - const store = new ComponentStore<{ name: string }>({ name: 'test' }); - store.updater((state, v: string) => ({...state, name: v, extra: true}))('test'); - `).toFail(/Remove excess properties/); + const store = new ComponentStore<{ name: string }>({ name: 'test' }); + store.updater((state, v: string) => ({ + ...state, + name: v, + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + extra: true, + }))('test'); }); }); }); From 1718bc1bee7aa1cee6191cb60b255aa15b17cf4c Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:15:21 -0400 Subject: [PATCH 5/8] test(component-store): fix updater excess-property type tests for TS 6.0 Under TypeScript 6.0 the branded exact-return-type error is reported on the updater callback argument (and the trailing invocation surfaces a second "Expected 0 arguments" error), so the inline @ts-expect-error placements did not match. Move the directive before the callback argument and drop the trailing invocation so exactly one expected error is suppressed per case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spec/types/component-store.types.spec.ts | 39 ++++++++++--------- .../spec/types/regression.types.spec.ts | 8 ++-- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/modules/component-store/spec/types/component-store.types.spec.ts b/modules/component-store/spec/types/component-store.types.spec.ts index 86d8527d49..c754b00a5d 100644 --- a/modules/component-store/spec/types/component-store.types.spec.ts +++ b/modules/component-store/spec/types/component-store.types.spec.ts @@ -352,11 +352,10 @@ describe('ComponentStore types', () => { prop: 'init', prop2: 'yeah!', }); - componentStore.updater((state, v: string) => ({ - ...state, + componentStore.updater( // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. - extraProp: 'bad', - }))('test'); + (state, v: string) => ({ ...state, extraProp: 'bad' }) + ); }); it('when extra property is returned with explicit object', () => { @@ -364,12 +363,14 @@ describe('ComponentStore types', () => { prop: 'init', prop2: 'yeah!', }); - componentStore.updater((state, v: string) => ({ - prop: v, - prop2: state.prop2, + componentStore.updater( // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. - extraProp: 'bad', - }))('test'); + (state, v: string) => ({ + prop: v, + prop2: state.prop2, + extraProp: 'bad', + }) + ); }); it('when extra property is returned from void updater', () => { @@ -377,11 +378,10 @@ describe('ComponentStore types', () => { prop: 'init', prop2: 'yeah!', }); - componentStore.updater((state) => ({ - ...state, + componentStore.updater( // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. - extraProp: true, - }))(); + (state) => ({ ...state, extraProp: true }) + ); }); it('when required property is missing', () => { @@ -389,8 +389,10 @@ describe('ComponentStore types', () => { prop: 'init', prop2: 'yeah!', }); - // @ts-expect-error Property 'prop2' is missing in type '{ prop: string; }' - componentStore.updater((state, v: string) => ({ prop: v }))('test'); + componentStore.updater( + // @ts-expect-error Property 'prop2' is missing in type '{ prop: string; }' + (state, v: string) => ({ prop: v }) + ); }); it('when property has wrong type', () => { @@ -398,11 +400,10 @@ describe('ComponentStore types', () => { prop: 'init', prop2: 'yeah!', }); - componentStore.updater((state, v: string) => ({ - ...state, + componentStore.updater( // @ts-expect-error Type 'number' is not assignable to type 'string' - prop: 123, - }))('test'); + (state, v: string) => ({ ...state, prop: 123 }) + ); }); it('allows spread with override', () => { diff --git a/modules/component-store/spec/types/regression.types.spec.ts b/modules/component-store/spec/types/regression.types.spec.ts index 558a4174f1..5f5afd1c33 100644 --- a/modules/component-store/spec/types/regression.types.spec.ts +++ b/modules/component-store/spec/types/regression.types.spec.ts @@ -46,12 +46,10 @@ describe('regression component-store', () => { it('should catch excess properties with concrete state type', () => { const store = new ComponentStore<{ name: string }>({ name: 'test' }); - store.updater((state, v: string) => ({ - ...state, - name: v, + store.updater( // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. - extra: true, - }))('test'); + (state, v: string) => ({ ...state, name: v, extra: true }) + ); }); }); }); From 25bfc183f968991580de2da5fda1b4b744205715 Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:38:31 -0400 Subject: [PATCH 6/8] test(component-store): document updater excess-property check on generic state Add a type test covering the known limitation where an unresolved generic state type (class MyStore extends ComponentStore) defers the excess-property check, causing spread-with-override callbacks to report a false positive. Verifies both documented workarounds (an `as T` assertion and returning state/T directly) compile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../spec/types/component-store.types.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/modules/component-store/spec/types/component-store.types.spec.ts b/modules/component-store/spec/types/component-store.types.spec.ts index c754b00a5d..a53421f245 100644 --- a/modules/component-store/spec/types/component-store.types.spec.ts +++ b/modules/component-store/spec/types/component-store.types.spec.ts @@ -451,5 +451,37 @@ describe('ComponentStore types', () => { expectTypeOf(v).toBeVoid(); }); }); + + describe('with a generic state type parameter', () => { + // When `ComponentStore` is extended with an unresolved generic state + // type, TypeScript cannot fully resolve the excess-property check, so + // spreading state and overriding a known property reports a false + // positive. Returning `state` directly, or asserting `as T`, is the + // documented workaround. + class GenericStore extends ComponentStore { + // Spreading state and overriding a known property reports a false + // positive here: while `T` is unresolved the excess-property check is + // deferred and cannot collapse to `never`, so the callback is rejected. + readonly setIdViaSpread = this.updater( + // @ts-expect-error known limitation: the excess-property check is + // deferred for an unresolved generic state type + (state, id: string) => ({ ...state, id }) + ); + + // Workaround 1: assert the return value as `T`. + readonly setIdViaAssertion = this.updater( + (state, id: string) => ({ ...state, id } as T) + ); + + // Workaround 2: return a full `T` (or `state`) directly. + readonly replaceViaDirectReturn = this.updater( + (_state, next: T) => next + ); + } + + it('documents the generic-state limitation and its workarounds', () => { + expectTypeOf(GenericStore).toBeConstructibleWith({ id: '1' }); + }); + }); }); }); From da5fe722a613b76f9d72b799c111be0463eaf41f Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:29:55 -0400 Subject: [PATCH 7/8] fix(store): support generic, union, nullable and index-signature state in exact return type check --- .../spec/types/component-store.types.spec.ts | 94 ++++++++-- .../component-store/src/component-store.ts | 34 +++- .../store/spec/types/reducer_creator.spec.ts | 173 ++++++++++++++++++ modules/store/src/models.ts | 31 +++- modules/store/src/reducer_creator.ts | 14 +- 5 files changed, 311 insertions(+), 35 deletions(-) diff --git a/modules/component-store/spec/types/component-store.types.spec.ts b/modules/component-store/spec/types/component-store.types.spec.ts index a53421f245..baeeae11e6 100644 --- a/modules/component-store/spec/types/component-store.types.spec.ts +++ b/modules/component-store/spec/types/component-store.types.spec.ts @@ -453,35 +453,93 @@ describe('ComponentStore types', () => { }); describe('with a generic state type parameter', () => { - // When `ComponentStore` is extended with an unresolved generic state - // type, TypeScript cannot fully resolve the excess-property check, so - // spreading state and overriding a known property reports a false - // positive. Returning `state` directly, or asserting `as T`, is the - // documented workaround. class GenericStore extends ComponentStore { - // Spreading state and overriding a known property reports a false - // positive here: while `T` is unresolved the excess-property check is - // deferred and cannot collapse to `never`, so the callback is rejected. - readonly setIdViaSpread = this.updater( - // @ts-expect-error known limitation: the excess-property check is - // deferred for an unresolved generic state type - (state, id: string) => ({ ...state, id }) + // Keys guaranteed by the constraint can be overridden via spread. + readonly setIdViaSpread = this.updater((state, id: string) => ({ + ...state, + id, + })); + + readonly returnState = this.updater((state) => state); + + readonly copyState = this.updater((state) => ({ ...state })); + + readonly replaceViaDirectReturn = this.updater( + (_state, next: T) => next ); - // Workaround 1: assert the return value as `T`. readonly setIdViaAssertion = this.updater( - (state, id: string) => ({ ...state, id } as T) + (state, id: string) => ({ ...state, id }) as T ); - // Workaround 2: return a full `T` (or `state`) directly. - readonly replaceViaDirectReturn = this.updater( - (_state, next: T) => next + // Keys not guaranteed by the constraint are excess properties. + readonly setExtra = this.updater( + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + (state, extra: string) => ({ ...state, extra }) ); } - it('documents the generic-state limitation and its workarounds', () => { + class ListStore extends ComponentStore<{ + items: Item[]; + loading: boolean; + }> { + readonly setItems = this.updater((state, items: Item[]) => ({ + ...state, + items, + loading: false, + })); + } + + class ExtendableStore extends ComponentStore< + { loading: boolean } & T + > { + readonly setLoading = this.updater((state) => ({ + ...state, + loading: true, + })); + } + + it('allows overriding known keys and catches excess keys', () => { expectTypeOf(GenericStore).toBeConstructibleWith({ id: '1' }); + expectTypeOf(ListStore).toBeConstructibleWith({ + items: [], + loading: false, + }); + expectTypeOf(ExtendableStore).toBeConstructibleWith({ loading: false }); + }); + }); + + describe('with union state', () => { + type FetchState = + { status: 'idle' } | { status: 'loaded'; data: string[] }; + + it('allows switching to a union member with its own keys', () => { + const componentStore = new ComponentStore({ + status: 'idle', + }); + componentStore.updater((_, data: string[]) => ({ + status: 'loaded' as const, + data, + })); }); + + it('catches keys that exist on no union member', () => { + const componentStore = new ComponentStore({ + status: 'idle', + }); + componentStore.updater( + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + () => ({ status: 'idle' as const, bogus: true }) + ); + }); + }); + + it('catches excess properties returned from one branch of a conditional', () => { + const componentStore = new ComponentStore({ prop: 'init' }); + componentStore.updater( + // @ts-expect-error updater callback return type must exactly match the state type. Remove excess properties. + (state, flag: boolean) => (flag ? { ...state, extra: true } : state) + ); }); }); }); diff --git a/modules/component-store/src/component-store.ts b/modules/component-store/src/component-store.ts index 7b62beb53e..79f79631f1 100644 --- a/modules/component-store/src/component-store.ts +++ b/modules/component-store/src/component-store.ts @@ -44,6 +44,29 @@ const excessPropertiesAreNotAllowedMsg = 'updater callback return type must exactly match the state type. Remove excess properties.'; type ExcessPropertiesAreNotAllowed = typeof excessPropertiesAreNotAllowedMsg; +type KeysOfUnion = T extends unknown ? keyof T : never; +// Keys a returned object may carry: any key of any union member, plus numeric keys for string index signatures. +type AllowedStateKeys = + keyof S | KeysOfUnion | (string extends keyof S ? number : never); +// `K` is inferred from the returned object's keys and constrained to AllowedStateKeys. Constraint checks use +// assignability, which respects generic constraints (e.g. `T extends { loading: boolean }`). When a key is not +// allowed, inference falls back to the constraint and the leftover keys are typed as the error message. +type ExactStateReturn = [R] extends [ + null | undefined, +] + ? unknown + : // Fast path: no key outside the state's keys (also resolves for identical generic types, e.g. `return state`) + [Exclude, AllowedStateKeys>] extends [never] + ? unknown + : { [P in K & PropertyKey]?: unknown } & { + [ + P in Exclude< + KeysOfUnion, + K | AllowedStateKeys + > as `${Msg} Excess property: ${P & string}` + ]: never; + }; + export interface SelectConfig { debounce?: boolean; equal?: ValueEqualityFn; @@ -138,14 +161,13 @@ export class ComponentStore implements OnDestroy { : (observableOrValue: ValueType | Observable) => Subscription, // Captures the actual return type to enforce exact state shape R extends T = T, + // Keys of the returned object, vetted against the state's keys via the constraint + K extends AllowedStateKeys = never, >( updaterFn: ( state: T, value: OriginType - ) => R & - (Exclude extends never - ? unknown - : ExcessPropertiesAreNotAllowed) + ) => R & ExactStateReturn ): ReturnType { return (( observableOrValue?: OriginType | Observable @@ -224,9 +246,7 @@ export class ComponentStore implements OnDestroy { */ patchState( partialStateOrUpdaterFn: - | Partial - | Observable> - | ((state: T) => Partial) + Partial | Observable> | ((state: T) => Partial) ): void { const patchedState = typeof partialStateOrUpdaterFn === 'function' diff --git a/modules/store/spec/types/reducer_creator.spec.ts b/modules/store/spec/types/reducer_creator.spec.ts index bcb0c208aa..3984d7137d 100644 --- a/modules/store/spec/types/reducer_creator.spec.ts +++ b/modules/store/spec/types/reducer_creator.spec.ts @@ -328,5 +328,178 @@ describe('createReducer()', () => { `).toFail(/not assignable to type/); }); }); + + describe('union, nullable and record state', () => { + it('should allow switching to a discriminated union member with its own keys', () => { + expectSnippet(` + type State = { status: 'idle' } | { status: 'loaded'; data: string[] }; + const load = createAction('load'); + const loaded = createAction('loaded', props<{ data: string[] }>()); + + const reducer = createReducer( + { status: 'idle' }, + on(load, () => ({ status: 'idle' as const })), + on(loaded, (_, { data }) => ({ status: 'loaded' as const, data })), + ); + `).toSucceed(); + }); + + it('should catch keys that exist on no union member', () => { + expectSnippet(` + type State = { status: 'idle' } | { status: 'loaded'; data: string[] }; + const load = createAction('load'); + + const reducer = createReducer( + { status: 'idle' }, + on(load, () => ({ status: 'idle' as const, bogus: true })), + ); + `).toFail(/Remove excess properties/); + }); + + it('should allow nullable state', () => { + expectSnippet(` + interface User { id: string; name: string }; + const login = createAction('login', props<{ user: User }>()); + const logout = createAction('logout'); + + const reducer = createReducer( + null, + on(login, (_, { user }) => user), + on(logout, () => null), + ); + `).toSucceed(); + }); + + it('should catch excess properties with nullable state', () => { + expectSnippet(` + interface User { id: string; name: string }; + const login = createAction('login'); + + const reducer = createReducer( + null, + on(login, () => ({ id: '1', name: 'a', extra: true })), + ); + `).toFail(/Remove excess properties/); + }); + + it('should allow Record state with computed keys', () => { + expectSnippet(` + const set = createAction('set', props<{ key: string; value: number }>()); + + const reducer = createReducer>( + {}, + on(set, (state, { key, value }) => ({ ...state, [key]: value })), + ); + `).toSucceed(); + }); + + it('should catch excess properties returned from one branch of a conditional', () => { + expectSnippet(` + interface State { name: string }; + const initialState: State = { name: 'test' }; + const update = createAction('update', props<{ flag: boolean }>()); + + const reducer = createReducer( + initialState, + on(update, (state, { flag }) => (flag ? { ...state, extra: true } : state)), + ); + `).toFail(/Remove excess properties/); + }); + + it('should name the excess property in the error', () => { + expectSnippet(` + interface State { name: string }; + const initialState: State = { name: 'test' }; + const update = createAction('update'); + + const reducer = createReducer( + initialState, + on(update, (state) => ({ ...state, extra: true })), + ); + `).toFail(/Excess property: extra/); + }); + }); + + describe('generic state', () => { + it('should allow overriding a key guaranteed by the constraint', () => { + expectSnippet(` + const load = createAction('load'); + + export function createLoadingReducer(initialState: TState) { + return createReducer( + initialState, + on(load, (state) => ({ ...state, loading: true })), + ); + } + `).toSucceed(); + }); + + it('should allow returning state with an unconstrained state type', () => { + expectSnippet(` + const noop = createAction('noop'); + + export function createNoopReducer(initialState: TState) { + return createReducer( + initialState, + on(noop, (state) => state), + ); + } + `).toSucceed(); + }); + + it('should allow a generic item type inside a concrete state shape', () => { + expectSnippet(` + interface ListState { items: T[]; loading: boolean }; + const load = createAction('load'); + + export function createListReducer(initialState: ListState) { + return createReducer( + initialState, + on(load, (state) => ({ ...state, loading: true })), + ); + } + `).toSucceed(); + }); + + it('should allow overriding a base key of an intersected generic state', () => { + expectSnippet(` + interface BaseState { loading: boolean }; + const load = createAction('load'); + + export function createExtendableReducer(initialState: BaseState & TExtra) { + return createReducer( + initialState, + on(load, (state) => ({ ...state, loading: true })), + ); + } + `).toSucceed(); + }); + + it('should allow asserting the return value as the generic state type', () => { + expectSnippet(` + const load = createAction('load'); + + export function createLoadingReducer(initialState: TState) { + return createReducer( + initialState, + on(load, (state) => ({ ...state, loading: true }) as TState), + ); + } + `).toSucceed(); + }); + + it('should catch keys not guaranteed by the constraint', () => { + expectSnippet(` + const load = createAction('load'); + + export function createLoadingReducer(initialState: TState) { + return createReducer( + initialState, + on(load, (state) => ({ ...state, extra: true })), + ); + } + `).toFail(/Remove excess properties/); + }); + }); }); }, 8_000); diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index e22d706676..9f6ad8247e 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -94,13 +94,7 @@ export type Creator< > = FunctionWithParametersType; export type Primitive = - | string - | number - | bigint - | boolean - | symbol - | null - | undefined; + string | number | bigint | boolean | symbol | null | undefined; export type NotAllowedCheck = T extends any[] ? ArraysAreNotAllowed @@ -180,4 +174,27 @@ export const excessPropertiesAreNotAllowedMsg = export type ExcessPropertiesAreNotAllowed = typeof excessPropertiesAreNotAllowedMsg; +type KeysOfUnion = T extends unknown ? keyof T : never; +// Keys a returned object may carry: any key of any union member, plus numeric keys for string index signatures. +export type AllowedStateKeys = + keyof S | KeysOfUnion | (string extends keyof S ? number : never); +// `K` is inferred from the returned object's keys and constrained to AllowedStateKeys. Constraint checks use +// assignability, which respects generic constraints (e.g. `T extends { loading: boolean }`). When a key is not +// allowed, inference falls back to the constraint and the leftover keys are typed as the error message. +export type ExactStateReturn = [R] extends [ + null | undefined, +] + ? unknown + : // Fast path: no key outside the state's keys (also resolves for identical generic types, e.g. `return state`) + [Exclude, AllowedStateKeys>] extends [never] + ? unknown + : { [P in K & PropertyKey]?: unknown } & { + [ + P in Exclude< + KeysOfUnion, + K | AllowedStateKeys + > as `${Msg} Excess property: ${P & string}` + ]: never; + }; + export type Prettify = { [K in keyof T]: T[K] } & {}; diff --git a/modules/store/src/reducer_creator.ts b/modules/store/src/reducer_creator.ts index 3f3a62f3e1..61f0dee7e9 100644 --- a/modules/store/src/reducer_creator.ts +++ b/modules/store/src/reducer_creator.ts @@ -4,6 +4,8 @@ import { ActionType, Action, ExcessPropertiesAreNotAllowed, + AllowedStateKeys, + ExactStateReturn, } from './models'; // Goes over the array of ActionCreators, pulls the action type out of each one @@ -72,6 +74,9 @@ export function on< EffectiveState = unknown extends State ? InferredState : State, // Captures the actual return type to enforce exact state shape — excess properties produce a descriptive type error R extends EffectiveState = EffectiveState, + // Keys of the returned object, vetted against the state's keys via the constraint + K extends AllowedStateKeys = + never, >( ...args: [ ...creators: Creators, @@ -79,9 +84,12 @@ export function on< state: unknown extends State ? InferredState : State, action: ActionType ) => R & - (Exclude extends never - ? unknown - : ExcessPropertiesAreNotAllowed), + ExactStateReturn< + R, + unknown extends State ? InferredState : State, + K, + ExcessPropertiesAreNotAllowed + >, ] ): ReducerTypes { const reducer = args.pop() as unknown as OnReducer< From e82e44f225a99a071a6aec3441671c649f65245d Mon Sep 17 00:00:00 2001 From: David <25187726+david-shortman@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:30:06 -0400 Subject: [PATCH 8/8] docs(store): remove generic state limitation from exact return type docs --- projects/www/src/app/pages/guide/component-store/write.md | 6 +++--- projects/www/src/app/pages/guide/store/reducers.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/projects/www/src/app/pages/guide/component-store/write.md b/projects/www/src/app/pages/guide/component-store/write.md index a8c591e9ec..4d07dffedf 100644 --- a/projects/www/src/app/pages/guide/component-store/write.md +++ b/projects/www/src/app/pages/guide/component-store/write.md @@ -59,8 +59,8 @@ export class MoviesStore extends ComponentStore { readonly addMovie = this.updater((state, movie: Movie) => ({ movies: [...state.movies, movie], - // TS error: 'updater()' callback return type must exactly match - // the state type. Remove excess properties. + // TS error: updater callback return type must exactly match the + // state type. Remove excess properties. Excess property: extra extra: true, })); } @@ -70,7 +70,7 @@ export class MoviesStore extends ComponentStore { -**Note:** When `ComponentStore` is extended with a generic state type parameter (e.g., `class MyStore extends ComponentStore`), TypeScript cannot fully resolve the excess property check. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as T`) as a workaround. +**Note:** When `ComponentStore` is extended with a generic state type parameter (e.g., `class MyStore extends ComponentStore`), properties guaranteed by the constraint can be overridden (`{ ...state, loading: true }`), while properties the constraint doesn't guarantee are reported as excess. diff --git a/projects/www/src/app/pages/guide/store/reducers.md b/projects/www/src/app/pages/guide/store/reducers.md index d3a3160475..73b2fde1b8 100644 --- a/projects/www/src/app/pages/guide/store/reducers.md +++ b/projects/www/src/app/pages/guide/store/reducers.md @@ -132,8 +132,8 @@ export const scoreboardReducer = createReducer( on(ScoreboardPageActions.homeScore, (state) => ({ ...state, home: state.home + 1, - // TS error: 'on()' callback return type must exactly match - // the state type. Remove excess properties. + // TS error: callback return type must exactly match the state + // type. Remove excess properties. Excess property: extra extra: true, })) ); @@ -143,7 +143,7 @@ export const scoreboardReducer = createReducer( -**Note:** When `on` is used inside a generic reducer factory where the state type is an unresolved generic parameter (e.g., `function createGenericReducer()`), TypeScript cannot fully resolve the excess property check. In those cases, callbacks that spread state and override known properties may produce a false type error. Return `state` directly or use a type assertion (`as TState`) as a workaround. +**Note:** When `on` is used inside a generic reducer factory (e.g., `function createLoadingReducer()`), properties guaranteed by the constraint can be overridden (`{ ...state, loading: true }`), while properties the constraint doesn't guarantee are reported as excess.