diff --git a/modules/signals/spec/types/signal-store-feature-type.spec.ts b/modules/signals/spec/types/signal-store-feature-type.spec.ts new file mode 100644 index 0000000000..532261598b --- /dev/null +++ b/modules/signals/spec/types/signal-store-feature-type.spec.ts @@ -0,0 +1,217 @@ +import { Signal } from '@angular/core'; +import { describe, expectTypeOf, it } from 'vitest'; +import { + signalStore, + 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().toMatchObjectType<{ + 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().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 }))); + } + + 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 {}; + }) + ); + }); + + 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 {}; + }) + ); + }); + }); + + 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 {}; + }) + ); + }); + + 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; + }>(); + }); +}); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 26b6b1278e..7891a3ea0d 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -8,6 +8,7 @@ export { EmptyFeatureResult, SignalStoreFeature, SignalStoreFeatureResult, + SignalStoreFeatureType, StateSignals, } from './signal-store-models'; export { diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index 73b633aa4e..2311cadabc 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -44,3 +44,39 @@ export type SignalStoreFeature< > = ( store: InnerSignalStore ) => InnerSignalStore; + +/** + * @description + * + * Extracts the state and members from a feature factory, allowing + * them to be reused as input in another `signalStoreFeature`. + * + * @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 + ? SignalStoreFeatureResult extends Input + ? Output + : Input & 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..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 @@ -307,6 +307,50 @@ export function withBaz() { The `withBaz` feature can only be used in a store where the property `foo` and the method `bar` are defined. +## Using `SignalStoreFeatureType` + +`SignalStoreFeatureType` can extract the state and members from a custom feature factory, and reuse it as the input type of another custom feature. + + + +```ts +import { SignalStoreFeatureType } from '@ngrx/signals'; + +export type RequestStatusFeature = SignalStoreFeatureType< + typeof withRequestStatus +>; +``` + + + + + +```ts +import { + signalStoreFeature, + type, + withComputed, +} from '@ngrx/signals'; +import { RequestStatusFeature } from './with-request-status'; + +export function withStatusMessage() { + return signalStoreFeature( + type(), + withComputed(({ isPending, error }) => ({ + statusMessage: () => { + if (isPending()) { + return 'Loading...'; + } + + return error() ?? 'Ready'; + }, + })) + ); +} +``` + + + ## Using `withFeature` An alternative approach to custom features with input is using the `withFeature` utility, which offers more flexibility.