Skip to content

Commit d57fa8c

Browse files
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<T> extends ComponentStore<T>), 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<State>({ 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
1 parent 151d7f2 commit d57fa8c

File tree

6 files changed

+132
-5
lines changed

6 files changed

+132
-5
lines changed

modules/component-store/spec/types/component-store.types.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,5 +273,61 @@ describe('ComponentStore types', () => {
273273
);
274274
});
275275
});
276+
277+
describe('catches excess properties', () => {
278+
it('when extra property is returned with spread', () => {
279+
expectSnippet(
280+
`componentStore.updater((state, v: string) => ({...state, extraProp: 'bad'}))('test');`
281+
).toFail(/Remove excess properties/);
282+
});
283+
284+
it('when extra property is returned with explicit object', () => {
285+
expectSnippet(
286+
`componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2, extraProp: 'bad' }))('test');`
287+
).toFail(/Remove excess properties/);
288+
});
289+
290+
it('when extra property is returned from void updater', () => {
291+
expectSnippet(
292+
`componentStore.updater((state) => ({...state, extraProp: true}))();`
293+
).toFail(/Remove excess properties/);
294+
});
295+
296+
it('when required property is missing', () => {
297+
expectSnippet(
298+
`componentStore.updater((state, v: string) => ({ prop: v }))('test');`
299+
).toFail(/is missing in type/);
300+
});
301+
302+
it('when property has wrong type', () => {
303+
expectSnippet(
304+
`componentStore.updater((state, v: string) => ({...state, prop: 123}))('test');`
305+
).toFail(/not assignable to type/);
306+
});
307+
308+
it('allows spread with override', () => {
309+
expectSnippet(
310+
`const sub = componentStore.updater((state, v: string) => ({...state, prop: v}))('test');`
311+
).toInfer('sub', 'Subscription');
312+
});
313+
314+
it('allows full explicit return matching all state keys', () => {
315+
expectSnippet(
316+
`const sub = componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2 }))('test');`
317+
).toInfer('sub', 'Subscription');
318+
});
319+
320+
it('allows void updater with spread return', () => {
321+
expectSnippet(
322+
`const v = componentStore.updater((state) => ({...state, prop: 'updated'}))();`
323+
).toInfer('v', 'void');
324+
});
325+
326+
it('allows direct state return', () => {
327+
expectSnippet(
328+
`const v = componentStore.updater((state) => state)();`
329+
).toInfer('v', 'void');
330+
});
331+
});
276332
});
277333
});

modules/component-store/spec/types/regression.types.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,27 @@ describe('regression component-store', () => {
4141
`;
4242
expectSnippet(effectTest).toSucceed();
4343
});
44+
45+
describe('updater exact return type', () => {
46+
it('should work with state containing optional properties', () => {
47+
expectSnippet(`
48+
const store = new ComponentStore<{ req: string; opt?: number }>({ req: 'a' });
49+
store.updater((state) => ({ req: 'b' }))();
50+
`).toSucceed();
51+
});
52+
53+
it('should work with state containing index signature', () => {
54+
expectSnippet(`
55+
const store = new ComponentStore<{ [key: string]: number }>({});
56+
store.updater((state, v: number) => ({...state, newKey: v}))(5);
57+
`).toSucceed();
58+
});
59+
60+
it('should catch excess properties with concrete state type', () => {
61+
expectSnippet(`
62+
const store = new ComponentStore<{ name: string }>({ name: 'test' });
63+
store.updater((state, v: string) => ({...state, name: v, extra: true}))('test');
64+
`).toFail(/Remove excess properties/);
65+
});
66+
});
4467
});

modules/component-store/src/component-store.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ import {
4040
import { isOnStateInitDefined, isOnStoreInitDefined } from './lifecycle_hooks';
4141
import { toSignal } from '@angular/core/rxjs-interop';
4242

43+
const excessPropertiesAreNotAllowedMsg =
44+
'updater callback return type must exactly match the state type. Remove excess properties.';
45+
type ExcessPropertiesAreNotAllowed = typeof excessPropertiesAreNotAllowedMsg;
46+
4347
export interface SelectConfig<T = unknown> {
4448
debounce?: boolean;
4549
equal?: ValueEqualityFn<T>;
@@ -132,7 +136,17 @@ export class ComponentStore<T extends object> implements OnDestroy {
132136
ReturnType = OriginType extends void
133137
? () => void
134138
: (observableOrValue: ValueType | Observable<ValueType>) => Subscription,
135-
>(updaterFn: (state: T, value: OriginType) => T): ReturnType {
139+
// Captures the actual return type to enforce exact state shape
140+
R extends T = T,
141+
>(
142+
updaterFn: (
143+
state: T,
144+
value: OriginType
145+
) => R &
146+
(Exclude<keyof R, keyof T> extends never
147+
? unknown
148+
: ExcessPropertiesAreNotAllowed)
149+
): ReturnType {
136150
return ((
137151
observableOrValue?: OriginType | Observable<OriginType>
138152
): Subscription => {
@@ -379,9 +393,8 @@ export class ComponentStore<T extends object> implements OnDestroy {
379393
// This type quickly became part of effect 'API'
380394
ProvidedType = void,
381395
// The actual origin$ type, which could be unknown, when not specified
382-
OriginType extends
383-
| Observable<ProvidedType>
384-
| unknown = Observable<ProvidedType>,
396+
OriginType extends Observable<ProvidedType> | unknown =
397+
Observable<ProvidedType>,
385398
// Unwrapped actual type of the origin$ Observable, after default was applied
386399
ObservableType = OriginType extends Observable<infer A> ? A : never,
387400
// Return either an optional callback or a function requiring specific types as inputs

modules/eslint-plugin/src/rules/component-store/updater-explicit-return-type.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ export default createRule<Options, MessageIds>({
1212
name: path.parse(__filename).name,
1313
meta: {
1414
type: 'problem',
15+
deprecated: true,
1516
docs: {
16-
description: '`Updater` should have an explicit return type.',
17+
description:
18+
'`Updater` should have an explicit return type. Deprecated: `ComponentStore.updater` now enforces exact return types at the type level.',
1719
ngrxModule: 'component-store',
1820
},
1921
schema: [],

projects/www/src/app/pages/guide/component-store/write.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ export class MoviesStore extends ComponentStore<MoviesState> {
4646

4747
</ngrx-code-example>
4848

49+
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:
50+
51+
<ngrx-code-example header="movies.store.ts">
52+
53+
```ts
54+
@Injectable()
55+
export class MoviesStore extends ComponentStore<MoviesState> {
56+
constructor() {
57+
super({ movies: [] });
58+
}
59+
60+
readonly addMovie = this.updater((state, movie: Movie) => ({
61+
movies: [...state.movies, movie],
62+
// TS error: 'updater()' callback return type must exactly match
63+
// the state type. Remove excess properties.
64+
extra: true,
65+
}));
66+
}
67+
```
68+
69+
</ngrx-code-example>
70+
71+
<ngrx-docs-alert type="inform">
72+
73+
**Note:** When `ComponentStore` is extended with a generic state type parameter (e.g., `class MyStore<T extends object> extends ComponentStore<T>`), 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.
74+
75+
</ngrx-docs-alert>
76+
4977
Updater then can be called with the values imperatively or could take an Observable.
5078

5179
<ngrx-code-example header="movies-page.component.ts">

projects/www/src/app/pages/guide/eslint-plugin/rules/updater-explicit-return-type.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
`Updater` should have an explicit return type.
44

55
- **Type**: problem
6+
- **Deprecated**: Yes
67
- **Fixable**: No
78
- **Suggestion**: No
89
- **Requires type checking**: No
@@ -11,6 +12,10 @@
1112
<!-- Everything above this generated, do not edit -->
1213
<!-- MANUAL-DOC:START -->
1314

15+
## Deprecation Notice
16+
17+
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.
18+
1419
## Rule Details
1520

1621
To enforce that the `updater` method from `@ngrx/component-store` returns the expected state interface, we must explicitly add the return type.

0 commit comments

Comments
 (0)