From a421716b639a50e8538a5b28a3a177e4c40c9c77 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Thu, 2 Jul 2026 00:46:48 +0200 Subject: [PATCH 01/12] feat(signals): add `SignalStoreFeatureType` This allows custom features to reuse another feature's inferred state, props, and methods as input without duplicating the feature result type manually. --- .../spec/types/signal-store-feature.test-d.ts | 108 ++++++++++++++++++ modules/signals/src/index.ts | 1 + modules/signals/src/signal-store-models.ts | 33 ++++++ .../signal-store/custom-store-features.md | 56 +++++++++ 4 files changed, 198 insertions(+) create mode 100644 modules/signals/spec/types/signal-store-feature.test-d.ts diff --git a/modules/signals/spec/types/signal-store-feature.test-d.ts b/modules/signals/spec/types/signal-store-feature.test-d.ts new file mode 100644 index 0000000000..235286ddb8 --- /dev/null +++ b/modules/signals/spec/types/signal-store-feature.test-d.ts @@ -0,0 +1,108 @@ +import { Signal } from '@angular/core'; +import { describe, expectTypeOf, it } from 'vitest'; +import { + signalStoreFeature, + SignalStoreFeatureType, + type, + withMethods, + withProps, + withState, +} from '../../src'; + +describe('SignalStoreFeatureType', () => { + function withCounter() { + return signalStoreFeature( + withState({ count: 0 }), + withMethods(() => ({ + increment(): void {}, + })) + ); + } + + it('uses extracted output as input for another custom feature', () => { + type CounterFeature = SignalStoreFeatureType; + + signalStoreFeature( + type(), + withMethods((store) => { + expectTypeOf(store).toMatchObjectType<{ + count: Signal; + increment: () => void; + }>(); + + return {}; + }) + ); + }); + + it('extracts the output result of a custom feature', () => { + type CounterFeature = SignalStoreFeatureType; + + expectTypeOf().toEqualTypeOf<{ + state: { count: number }; + methods: { increment: () => void }; + props: {}; + }>(); + }); + + it('extracts output from generic feature factories', () => { + function withContainer(initialValue: T) { + return signalStoreFeature(withProps(() => ({ a: initialValue }))); + } + + type ContainerType = SignalStoreFeatureType>; + + expectTypeOf().toEqualTypeOf<{ + state: {}; + methods: {}; + props: { a: number }; + }>(); + }); + + describe('intersections', () => { + function withContainer(initialValue: T) { + return signalStoreFeature(withProps(() => ({ a: initialValue }))); + } + + function withCounter() { + return signalStoreFeature( + withState({ count: 0 }), + withMethods(() => ({ + increment(): void {}, + })) + ); + } + + type CounterContainerFeature = SignalStoreFeatureType & + SignalStoreFeatureType>; + + it('preserves state, props, and methods from intersected feature outputs', () => { + expectTypeOf().toMatchObjectType<{ + state: { + count: number; + }; + methods: { + increment: () => void; + }; + props: { + a: string; + }; + }>(); + }); + + it('uses intersected feature outputs as input for another custom feature', () => { + signalStoreFeature( + type(), + withMethods((store) => { + expectTypeOf(store).toMatchObjectType<{ + count: Signal; + increment: () => void; + a: string; + }>(); + + return {}; + }) + ); + }); + }); +}); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 26b6b1278e..41ede1cbc4 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -9,6 +9,7 @@ export { SignalStoreFeature, SignalStoreFeatureResult, StateSignals, + type SignalStoreFeatureType, } from './signal-store-models'; export { getState, diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index 73b633aa4e..db5e7a6410 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -44,3 +44,36 @@ export type SignalStoreFeature< > = ( store: InnerSignalStore ) => InnerSignalStore; + +/** + * @description + * + * Extracts the output type of a SignalStore feature factory. + * + * @usageNotes + * + * ```ts + * function withFeatureA() { + * return signalStoreFeature(withState({ foo: 'bar' })); + * } + * + * type FeatureA = SignalStoreFeatureType; + * + * function withFeatureB() { + * return signalStoreFeature( + * type(), + * withMethods(({ foo }) => ({ + * logFoo(): void { + * console.log(foo()); + * }, + * })) + * ); + * } + * ``` + */ +export type SignalStoreFeatureType< + Feature extends (...params: never[]) => unknown, +> = + ReturnType extends SignalStoreFeature + ? Output + : never; diff --git a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md index 5a91f38e03..f5398d8b12 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md @@ -184,6 +184,8 @@ This enables the utilization of input properties within the custom feature, even The expected input type should be defined as the first argument of the `signalStoreFeature` function, using the `type` helper function from the `@ngrx/signals` package. +`SignalStoreFeatureType` helper can extract the complete output type of a custom feature factory and reuse it as the input type of another custom feature. + It's recommended to define loosely-coupled/independent features whenever possible. @@ -307,6 +309,60 @@ export function withBaz() { The `withBaz` feature can only be used in a store where the property `foo` and the method `bar` are defined. +### Example 5: Reusing Feature Output as Input + +`SignalStoreFeatureType` extracts the output type of `withFooBar` and improves the developer experience when another custom feature depends on this output. + + + +```ts +import { computed } from '@angular/core'; +import { + signalStoreFeature, + SignalStoreFeatureType, + withComputed, + withMethods, +} from '@ngrx/signals'; + +export function withFooBar() { + return signalStoreFeature( + withComputed(() => ({ + foo: computed(() => 10), + })), + withMethods(() => ({ + bar(foo: number): void { + console.log(foo); + }, + })) + ); +} + +export type FooBarFeature = SignalStoreFeatureType; +``` + + + + + +```ts +import { signalStoreFeature, type, withMethods } from '@ngrx/signals'; +import { FooBarFeature } from './with-foo-bar'; + +export function withBaz() { + return signalStoreFeature( + type(), + withMethods((store) => ({ + baz(): void { + const foo = store.foo(); + store.bar(foo); + }, + })) + ); +} +``` + + + ## Using `withFeature` An alternative approach to custom features with input is using the `withFeature` utility, which offers more flexibility. From 6022fb6cdfb28a17f684a4c2dbe346efd2f08a58 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Sat, 4 Jul 2026 15:54:51 +0200 Subject: [PATCH 02/12] feat: add edge cases to tests --- .../spec/types/signal-store-feature.test-d.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/modules/signals/spec/types/signal-store-feature.test-d.ts b/modules/signals/spec/types/signal-store-feature.test-d.ts index 235286ddb8..105ad991c3 100644 --- a/modules/signals/spec/types/signal-store-feature.test-d.ts +++ b/modules/signals/spec/types/signal-store-feature.test-d.ts @@ -104,5 +104,32 @@ describe('SignalStoreFeatureType', () => { }) ); }); + + it('uses inline', () => { + signalStoreFeature( + type(), + withMethods((store) => { + expectTypeOf(store).toMatchObjectType<{ + count: Signal; + value: Signal; + increment: () => void; + a: string; + }>(); + + return {}; + }) + ); + }); + + it('intersects on the same member, which results in a never', () => { + signalStoreFeature( + type(), + withMethods((store) => { + expectTypeOf(store.count).toEqualTypeOf>(); + + return {}; + }) + ); + }); }); }); From 34be301f7acbe7eefe513ab9a960355ee47c4aac Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Sun, 5 Jul 2026 22:25:12 +0200 Subject: [PATCH 03/12] feat: support features with input --- .../spec/types/signal-store-feature.test-d.ts | 47 ++++++++++++++++++- modules/signals/src/signal-store-models.ts | 4 +- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/modules/signals/spec/types/signal-store-feature.test-d.ts b/modules/signals/spec/types/signal-store-feature.test-d.ts index 105ad991c3..cdccd06512 100644 --- a/modules/signals/spec/types/signal-store-feature.test-d.ts +++ b/modules/signals/spec/types/signal-store-feature.test-d.ts @@ -38,7 +38,7 @@ describe('SignalStoreFeatureType', () => { it('extracts the output result of a custom feature', () => { type CounterFeature = SignalStoreFeatureType; - expectTypeOf().toEqualTypeOf<{ + expectTypeOf().toMatchObjectType<{ state: { count: number }; methods: { increment: () => void }; props: {}; @@ -52,13 +52,56 @@ describe('SignalStoreFeatureType', () => { type ContainerType = SignalStoreFeatureType>; - expectTypeOf().toEqualTypeOf<{ + expectTypeOf().toMatchObjectType<{ state: {}; methods: {}; props: { a: number }; }>(); }); + it('preserves required input from custom features', () => { + function withCounterLogger() { + return signalStoreFeature( + { + state: type<{ count: number }>(), + methods: type<{ increment: () => void }>(), + }, + withMethods(({ count, increment }) => ({ + logAndIncrement(): void { + console.log(count()); + increment(); + }, + })) + ); + } + + type CounterLoggerFeature = SignalStoreFeatureType< + typeof withCounterLogger + >; + + expectTypeOf().toMatchObjectType<{ + state: { count: number }; + methods: { + increment: () => void; + logAndIncrement: () => void; + }; + props: {}; + }>(); + + signalStoreFeature( + type(), + withMethods((store) => { + expectTypeOf(store).toMatchObjectType<{ + count: Signal; + increment: () => void; + logAndIncrement: () => void; + }>(); + + return {}; + }) + ); + }); + describe('intersections', () => { function withContainer(initialValue: T) { return signalStoreFeature(withProps(() => ({ a: initialValue }))); diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index db5e7a6410..6863fb98b3 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -74,6 +74,6 @@ export type SignalStoreFeature< export type SignalStoreFeatureType< Feature extends (...params: never[]) => unknown, > = - ReturnType extends SignalStoreFeature - ? Output + ReturnType extends SignalStoreFeature + ? Input & Output : never; From 0f6c7219583b1f6bfbaa63c54277112f27baf332 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 4 Aug 2026 23:47:35 +0200 Subject: [PATCH 04/12] refactor: rename test file --- ...tore-feature.test-d.ts => signal-store-feature-type.test-d.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename modules/signals/spec/types/{signal-store-feature.test-d.ts => signal-store-feature-type.test-d.ts} (100%) diff --git a/modules/signals/spec/types/signal-store-feature.test-d.ts b/modules/signals/spec/types/signal-store-feature-type.test-d.ts similarity index 100% rename from modules/signals/spec/types/signal-store-feature.test-d.ts rename to modules/signals/spec/types/signal-store-feature-type.test-d.ts From 5aa43a73e337f35addcb1ffe4bb0f0f170d1b5a8 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Tue, 4 Aug 2026 23:48:51 +0200 Subject: [PATCH 05/12] Update modules/signals/src/index.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Marko Stanimirović --- modules/signals/src/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 41ede1cbc4..56a4da4f52 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -10,6 +10,12 @@ export { SignalStoreFeatureResult, StateSignals, type SignalStoreFeatureType, +export { + EmptyFeatureResult, + SignalStoreFeature, + SignalStoreFeatureType, + SignalStoreFeatureResult, + StateSignals, } from './signal-store-models'; export { getState, From f7c60cb9ccd906a2bc19d166db5d25d66a99de06 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 00:16:31 +0200 Subject: [PATCH 06/12] feat: handle bare feature functions --- ...t-d.ts => signal-store-feature-type.spec.ts} | 17 +++++++++++++++++ modules/signals/src/signal-store-models.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) rename modules/signals/spec/types/{signal-store-feature-type.test-d.ts => signal-store-feature-type.spec.ts} (90%) diff --git a/modules/signals/spec/types/signal-store-feature-type.test-d.ts b/modules/signals/spec/types/signal-store-feature-type.spec.ts similarity index 90% rename from modules/signals/spec/types/signal-store-feature-type.test-d.ts rename to modules/signals/spec/types/signal-store-feature-type.spec.ts index cdccd06512..6decab452d 100644 --- a/modules/signals/spec/types/signal-store-feature-type.test-d.ts +++ b/modules/signals/spec/types/signal-store-feature-type.spec.ts @@ -175,4 +175,21 @@ describe('SignalStoreFeatureType', () => { ); }); }); + + it('ignores unresolved input from bare feature factories', () => { + function withLogger() { + return withMethods(() => ({ log(): void {} })); + } + + signalStoreFeature( + type>(), + withMethods((store) => { + expectTypeOf(store.log).toEqualTypeOf<() => void>(); + // @ts-expect-error no Function index signature + store.anythingAtAll(); + + return {}; + }) + ); + }); }); diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index 6863fb98b3..ef0fde3a74 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -75,5 +75,7 @@ export type SignalStoreFeatureType< Feature extends (...params: never[]) => unknown, > = ReturnType extends SignalStoreFeature - ? Input & Output + ? SignalStoreFeatureResult extends Input + ? Output + : Input & Output : never; From ae0f3e9a1925e630aa313f5b33dc17d3c963d826 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 15:06:31 +0200 Subject: [PATCH 07/12] feat: improve docs as requested --- .../signal-store/custom-store-features.md | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md index f5398d8b12..34f052cc9b 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md @@ -184,8 +184,6 @@ This enables the utilization of input properties within the custom feature, even The expected input type should be defined as the first argument of the `signalStoreFeature` function, using the `type` helper function from the `@ngrx/signals` package. -`SignalStoreFeatureType` helper can extract the complete output type of a custom feature factory and reuse it as the input type of another custom feature. - It's recommended to define loosely-coupled/independent features whenever possible. @@ -309,52 +307,42 @@ export function withBaz() { The `withBaz` feature can only be used in a store where the property `foo` and the method `bar` are defined. -### Example 5: Reusing Feature Output as Input +## Using `SignalStoreFeatureType` -`SignalStoreFeatureType` extracts the output type of `withFooBar` and improves the developer experience when another custom feature depends on this output. +`SignalStoreFeatureType` helper can extract the complete output type of a custom feature factory and reuse it as the input type of another custom feature. - + ```ts -import { computed } from '@angular/core'; -import { - signalStoreFeature, - SignalStoreFeatureType, - withComputed, - withMethods, -} from '@ngrx/signals'; +import { SignalStoreFeatureType } from '@ngrx/signals'; -export function withFooBar() { - return signalStoreFeature( - withComputed(() => ({ - foo: computed(() => 10), - })), - withMethods(() => ({ - bar(foo: number): void { - console.log(foo); - }, - })) - ); -} - -export type FooBarFeature = SignalStoreFeatureType; +export type RequestStatusFeature = SignalStoreFeatureType< + typeof withRequestStatus +>; ``` - + ```ts -import { signalStoreFeature, type, withMethods } from '@ngrx/signals'; -import { FooBarFeature } from './with-foo-bar'; +import { + signalStoreFeature, + type, + withComputed, +} from '@ngrx/signals'; +import { RequestStatusFeature } from './with-request-status'; -export function withBaz() { +export function withStatusMessage() { return signalStoreFeature( - type(), - withMethods((store) => ({ - baz(): void { - const foo = store.foo(); - store.bar(foo); + type(), + withComputed(({ isPending, error }) => ({ + statusMessage: () => { + if (isPending()) { + return 'Loading...'; + } + + return error() ?? 'Ready'; }, })) ); From 1500435dea2117722791de3a8cd5e920a0c0910d Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 19:25:55 +0200 Subject: [PATCH 08/12] feat: improve jsdoc --- modules/signals/src/signal-store-models.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index ef0fde3a74..2311cadabc 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -48,7 +48,8 @@ export type SignalStoreFeature< /** * @description * - * Extracts the output type of a SignalStore feature factory. + * Extracts the state and members from a feature factory, allowing + * them to be reused as input in another `signalStoreFeature`. * * @usageNotes * From bba8bc634dd62911a7ad8c26ec42de309cc4cbc6 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 20:06:16 +0200 Subject: [PATCH 09/12] docs: improve docs --- .../pages/guide/signals/signal-store/custom-store-features.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md index 34f052cc9b..78617a060a 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/custom-store-features.md @@ -309,7 +309,7 @@ The `withBaz` feature can only be used in a store where the property `foo` and t ## Using `SignalStoreFeatureType` -`SignalStoreFeatureType` helper can extract the complete output type of a custom feature factory and reuse it as the input type of another custom feature. +`SignalStoreFeatureType` can extract the state and members from a custom feature factory, and reuse it as the input type of another custom feature. From 51db93e5a97320c5614a7656d928d8a007a86efc Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 20:19:04 +0200 Subject: [PATCH 10/12] feat: add test which checks the outcome of a whole signalStore --- .../types/signal-store-feature-type.spec.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/modules/signals/spec/types/signal-store-feature-type.spec.ts b/modules/signals/spec/types/signal-store-feature-type.spec.ts index 6decab452d..532261598b 100644 --- a/modules/signals/spec/types/signal-store-feature-type.spec.ts +++ b/modules/signals/spec/types/signal-store-feature-type.spec.ts @@ -1,6 +1,7 @@ import { Signal } from '@angular/core'; import { describe, expectTypeOf, it } from 'vitest'; import { + signalStore, signalStoreFeature, SignalStoreFeatureType, type, @@ -192,4 +193,25 @@ describe('SignalStoreFeatureType', () => { }) ); }); + + it('does a full check on the `signalStore` outcome', () => { + function withCounterLogger() { + return signalStoreFeature( + type>(), + withMethods(({ count, increment }) => ({ + logAndIncrement(): void { + increment(); + }, + })) + ); + } + + const CounterStore = signalStore(withCounter(), withCounterLogger()); + + expectTypeOf>().toMatchObjectType<{ + count: Signal; + increment: () => void; + logAndIncrement: () => void; + }>(); + }); }); From 53d90d710fa54bbc8fcb905e87f2ba828621c744 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 20:30:13 +0200 Subject: [PATCH 11/12] fix: imports in index --- modules/signals/src/index.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 56a4da4f52..41ede1cbc4 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -10,12 +10,6 @@ export { SignalStoreFeatureResult, StateSignals, type SignalStoreFeatureType, -export { - EmptyFeatureResult, - SignalStoreFeature, - SignalStoreFeatureType, - SignalStoreFeatureResult, - StateSignals, } from './signal-store-models'; export { getState, From 53a41fd9601868fea02bd8936377f43d173f6dd1 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Wed, 5 Aug 2026 20:31:10 +0200 Subject: [PATCH 12/12] fix: remove type in index.ts --- modules/signals/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 41ede1cbc4..7891a3ea0d 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -8,8 +8,8 @@ export { EmptyFeatureResult, SignalStoreFeature, SignalStoreFeatureResult, + SignalStoreFeatureType, StateSignals, - type SignalStoreFeatureType, } from './signal-store-models'; export { getState,