feat(store): enforce exact return types in on() and updater() callbacks#5078
feat(store): enforce exact return types in on() and updater() callbacks#5078david-shortman wants to merge 3 commits intongrx:mainfrom
Conversation
✅ Deploy Preview for ngrx-io ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
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 ngrx#4280
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<TState>),
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 ngrx#4280
f5a3b4b to
23aa053
Compare
|
CI failures seem unrelated to this PR: effects:test example-app-e2e |
timdeschryver
left a comment
There was a problem hiding this comment.
Because this is a breaking change, the earliest that this can be released is v22.
It will also require a discussion first.
| name: path.parse(__filename).name, | ||
| meta: { | ||
| type: 'problem', | ||
| deprecated: true, |
There was a problem hiding this comment.
Is there a reason/advantage to deprecate the rules instead of removing them?
There was a problem hiding this comment.
I suppose they actually don't have value when the return types are no longer needed. So might as well just remove.
|
|
||
| </ngrx-code-example> | ||
|
|
||
| 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: |
There was a problem hiding this comment.
| 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: |
|
|
||
| ### 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: |
There was a problem hiding this comment.
| 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: |
|
|
||
| <ngrx-docs-alert type="inform"> | ||
|
|
||
| **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. |
There was a problem hiding this comment.
I think mentioning "because keyof T is deferred" is not very useful here for our users. Additionally, adding a test case for this scenario can be useful.
| **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. | |
| **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. 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. |
✅ Deploy Preview for ngrx-site-v21 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
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 <noreply@anthropic.com>
ba777e1 to
696698f
Compare
PR Checklist
Please check if your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
TypeScript does not perform excess property checking on callback return values. This means that
on()andComponentStore.updater()callbacks can return objects with extra properties that don't exist on the state type, and TypeScript won't report an error:The
on-function-explicit-return-typeandupdater-explicit-return-typeESLint rules exist as workarounds, requiring developers to add explicit return type annotations to trigger TypeScript's excess property checking.Closes #4280
What is the new behavior?
Both
on()andComponentStore.updater()now enforce exact return types at the type level. The callback return type is captured as a genericR, and a conditional check determines whetherRcontains keys not present in the state type. If excess keys are found, the return type is intersected with a descriptive string literal type, producing a clear compile-time error with an actionable message:The custom type error messages follow the same pattern used in ngrx action creators (e.g.,
'action creator cannot return an array'), providing actionable guidance instead of crypticnevertype errors.No explicit return type annotations are needed. The following patterns continue to work without changes:
(state) => ({ ...state, name: 'updated' })initialStateorstatedirectlyon()statedirectlyThe
updater-explicit-return-typeandon-function-explicit-return-typeESLint rules are deprecated since the type system now handles this directly.Limitation with generic state types
When the state type is an unresolved generic parameter — e.g.,
class MyStore<T> extends ComponentStore<T>orfunction createGenericReducer<TState>()— TypeScript cannot fully resolve the excess property check becausekeyof Tis deferred. This means callbacks that spread state and override known properties (e.g.,{...state, id: value}) will produce a false type error in those contexts. The workaround is to returnstatedirectly or use a type assertion (as T). This is a TypeScript limitation with deferred conditional types on generic parameters, not something that can be solved at the library level. The deprecated ESLint rules do not help in this case either.Does this PR introduce a breaking change?
Other information
Validated with
ts-snippettype tests covering: spread patterns, explicit returns, void callbacks, direct state returns,initialStatereturns, optional properties, index signatures, multiple action creators, generic reducer factories, missing properties, wrong property types, and excess property detection in bothcreateReducer-wrapped and standaloneon()usage.