Skip to content

Commit 6582ebd

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. 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 6582ebd

5 files changed

Lines changed: 106 additions & 5 deletions

File tree

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/eslint-plugin/rules/updater-explicit-return-type.md

Lines changed: 7 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,12 @@
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.
18+
19+
Note: This type-level enforcement does not apply when the store's state type is an unresolved generic parameter (e.g., `class MyStore<T> extends ComponentStore<T>`). In those cases, explicit return type annotations are still recommended.
20+
1421
## Rule Details
1522

1623
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)