Skip to content

Comments

feat(store): enforce exact return types in on() and updater() callbacks#5078

Open
david-shortman wants to merge 3 commits intongrx:mainfrom
david-shortman:fix/exact-return-type-enforcement
Open

feat(store): enforce exact return types in on() and updater() callbacks#5078
david-shortman wants to merge 3 commits intongrx:mainfrom
david-shortman:fix/exact-return-type-enforcement

Conversation

@david-shortman
Copy link
Contributor

@david-shortman david-shortman commented Jan 30, 2026

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

[ ] Bugfix
[x] Feature
[ ] Code style update (formatting, local variables)
[ ] Refactoring (no functional changes, no api changes)
[ ] Build related changes
[ ] CI related changes
[ ] Documentation content changes
[ ] Other... Please describe:

What is the current behavior?

TypeScript does not perform excess property checking on callback return values. This means that on() and ComponentStore.updater() callbacks can return objects with extra properties that don't exist on the state type, and TypeScript won't report an error:

// No compilation error — `extra` is silently accepted
on(setName, (state, { name }) => ({ ...state, name, extra: true }))

// No compilation error — `extra` is silently accepted
this.updater((state, value: string) => ({ ...state, name: value, extra: true }))

The on-function-explicit-return-type and updater-explicit-return-type ESLint 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() and ComponentStore.updater() now enforce exact return types at the type level. The callback return type is captured as a generic R, and a conditional check determines whether R contains 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:

// TS error: Type '{ extra: boolean; ... }' is not assignable to type
// '"callback return type must exactly match the state type. Remove excess properties."'
on(setName, (state, { name }) => ({ ...state, name, extra: true }))

// TS error: Type '{ extra: boolean; ... }' is not assignable to type
// '"updater callback return type must exactly match the state type. Remove excess properties."'
this.updater((state, value: string) => ({ ...state, name: value, extra: true }))

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 cryptic never type errors.

No explicit return type annotations are needed. The following patterns continue to work without changes:

  • Spread with overrides: (state) => ({ ...state, name: 'updated' })
  • Returning initialState or state directly
  • States with optional properties or index signatures
  • Multiple action creators in on()
  • Generic reducer factories that return state directly

The updater-explicit-return-type and on-function-explicit-return-type ESLint 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> or function createGenericReducer<TState>() — TypeScript cannot fully resolve the excess property check because keyof T is 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 return state directly 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?

[x] Yes
[ ] No
BREAKING CHANGES:

The updater() and on() callback return types are now strictly checked
for excess properties. Code that previously compiled with extra
properties in these callbacks will now produce TypeScript compilation
errors with a descriptive message.

When the state type is an unresolved generic parameter (e.g.
class MyStore<T> extends ComponentStore<T>, or a generic reducer
factory function), 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:

const reducer = createReducer(
  initialState,
  on(setName, (state, { name }) => ({
    ...state,
    name,
    extra: true, // no error
  })),
);

this.updater((state, value: string) => ({
  ...state,
  name: value,
  extra: true, // no error
}))

AFTER:

// Type '{ extra: 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,
    extra: true, // TS error with descriptive message
  })),
);

// Type '{ extra: boolean; ... }' is not assignable to type
// '"updater callback return type must exactly match the state type.
//  Remove excess properties."'
this.updater((state, value: string) => ({
  ...state,
  name: value,
  extra: true, // TS error with descriptive message
}))

Other information

Validated with ts-snippet type tests covering: spread patterns, explicit returns, void callbacks, direct state returns, initialState returns, optional properties, index signatures, multiple action creators, generic reducer factories, missing properties, wrong property types, and excess property detection in both createReducer-wrapped and standalone on() usage.

@netlify
Copy link

netlify bot commented Jan 30, 2026

Deploy Preview for ngrx-io ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 696698f
🔍 Latest deploy log https://app.netlify.com/projects/ngrx-io/deploys/6999560d5f46b90008c4a2b6
😎 Deploy Preview https://deploy-preview-5078--ngrx-io.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

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
@david-shortman david-shortman force-pushed the fix/exact-return-type-enforcement branch from f5a3b4b to 23aa053 Compare January 30, 2026 01:08
@david-shortman
Copy link
Contributor Author

CI failures seem unrelated to this PR:

effects:test
Single test (should enforce an Action return value) timed out at 8s. This is a flaky ts-snippet test. Passes locally.

example-app-e2e
Cypress times out looking for bc-book-preview. This reproduces locally with the same failure, but I think it's a pre-existing issue with the example app.

@david-shortman david-shortman marked this pull request as ready for review January 30, 2026 01:26
Copy link
Member

@timdeschryver timdeschryver left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason/advantage to deprecate the rules instead of removing them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
**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.

@netlify
Copy link

netlify bot commented Feb 21, 2026

Deploy Preview for ngrx-site-v21 ready!

Name Link
🔨 Latest commit 696698f
🔍 Latest deploy log https://app.netlify.com/projects/ngrx-site-v21/deploys/6999560de3c9f70008fea40b
😎 Deploy Preview https://deploy-preview-5078--ngrx-site-v21.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

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>
@david-shortman david-shortman force-pushed the fix/exact-return-type-enforcement branch from ba777e1 to 696698f Compare February 21, 2026 06:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

updater-explicit-return-type No longer needed with NoInfer

2 participants