Skip to content
Merged
217 changes: 217 additions & 0 deletions modules/signals/spec/types/signal-store-feature-type.spec.ts
Comment thread
markostanimirovic marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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<typeof withCounter>;

signalStoreFeature(
type<CounterFeature>(),
withMethods((store) => {
expectTypeOf(store).toMatchObjectType<{
count: Signal<number>;
increment: () => void;
}>();

return {};
})
);
Comment thread
markostanimirovic marked this conversation as resolved.
});

it('extracts the output result of a custom feature', () => {
type CounterFeature = SignalStoreFeatureType<typeof withCounter>;

expectTypeOf<CounterFeature>().toMatchObjectType<{
state: { count: number };
methods: { increment: () => void };
props: {};
}>();
});

it('extracts output from generic feature factories', () => {
function withContainer<T>(initialValue: T) {
return signalStoreFeature(withProps(() => ({ a: initialValue })));
}

type ContainerType = SignalStoreFeatureType<typeof withContainer<number>>;

expectTypeOf<ContainerType>().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<CounterLoggerFeature>().toMatchObjectType<{
state: { count: number };
methods: {
increment: () => void;
logAndIncrement: () => void;
};
props: {};
}>();

signalStoreFeature(
type<CounterLoggerFeature>(),
withMethods((store) => {
expectTypeOf(store).toMatchObjectType<{
count: Signal<number>;
increment: () => void;
logAndIncrement: () => void;
}>();

return {};
})
);
});

describe('intersections', () => {
function withContainer<T>(initialValue: T) {
return signalStoreFeature(withProps(() => ({ a: initialValue })));
}

function withCounter() {
return signalStoreFeature(
withState({ count: 0 }),
withMethods(() => ({
increment(): void {},
}))
);
}

type CounterContainerFeature = SignalStoreFeatureType<typeof withCounter> &
SignalStoreFeatureType<typeof withContainer<string>>;

it('preserves state, props, and methods from intersected feature outputs', () => {
expectTypeOf<CounterContainerFeature>().toMatchObjectType<{
state: {
count: number;
};
methods: {
increment: () => void;
};
props: {
a: string;
};
}>();
});

it('uses intersected feature outputs as input for another custom feature', () => {
signalStoreFeature(
type<CounterContainerFeature>(),
withMethods((store) => {
expectTypeOf(store).toMatchObjectType<{
count: Signal<number>;
increment: () => void;
a: string;
}>();

return {};
})
);
});

it('uses inline', () => {
signalStoreFeature(
type<CounterContainerFeature & { state: { value: number } }>(),
withMethods((store) => {
expectTypeOf(store).toMatchObjectType<{
count: Signal<number>;
value: Signal<number>;
increment: () => void;
a: string;
}>();

return {};
})
);
});

it('intersects on the same member, which results in a never', () => {
signalStoreFeature(
type<CounterContainerFeature & { state: { count: string } }>(),
withMethods((store) => {
expectTypeOf(store.count).toEqualTypeOf<Signal<never>>();

return {};
})
);
});
});

it('ignores unresolved input from bare feature factories', () => {
function withLogger() {
return withMethods(() => ({ log(): void {} }));
}

signalStoreFeature(
type<SignalStoreFeatureType<typeof withLogger>>(),
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<SignalStoreFeatureType<typeof withCounter>>(),
withMethods(({ count, increment }) => ({
logAndIncrement(): void {
increment();
},
}))
);
}

const CounterStore = signalStore(withCounter(), withCounterLogger());

expectTypeOf<InstanceType<typeof CounterStore>>().toMatchObjectType<{
count: Signal<number>;
increment: () => void;
logAndIncrement: () => void;
}>();
});
});
1 change: 1 addition & 0 deletions modules/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
EmptyFeatureResult,
SignalStoreFeature,
SignalStoreFeatureResult,
SignalStoreFeatureType,
StateSignals,
} from './signal-store-models';
export {
Expand Down
36 changes: 36 additions & 0 deletions modules/signals/src/signal-store-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,39 @@ export type SignalStoreFeature<
> = (
store: InnerSignalStore<Input['state'], Input['props'], Input['methods']>
) => InnerSignalStore<Output['state'], Output['props'], Output['methods']>;

/**
* @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<typeof withFeatureA>;
*
* function withFeatureB() {
* return signalStoreFeature(
* type<FeatureA>(),
* withMethods(({ foo }) => ({
* logFoo(): void {
* console.log(foo());
* },
* }))
* );
* }
* ```
*/
export type SignalStoreFeatureType<
Feature extends (...params: never[]) => unknown,
> =
ReturnType<Feature> extends SignalStoreFeature<infer Input, infer Output>
? SignalStoreFeatureResult extends Input
? Output
: Input & Output
: never;
Comment thread
markostanimirovic marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,50 @@ export function withBaz<Foo extends string | number>() {

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.

<ngrx-code-example header="with-request-status.ts">

```ts
import { SignalStoreFeatureType } from '@ngrx/signals';

export type RequestStatusFeature = SignalStoreFeatureType<
typeof withRequestStatus
>;
```

</ngrx-code-example>

<ngrx-code-example header="with-status-message.ts">

```ts
import {
signalStoreFeature,
type,
withComputed,
} from '@ngrx/signals';
import { RequestStatusFeature } from './with-request-status';

export function withStatusMessage() {
return signalStoreFeature(
type<RequestStatusFeature>(),
withComputed(({ isPending, error }) => ({
statusMessage: () => {
if (isPending()) {
return 'Loading...';
}

return error() ?? 'Ready';
},
}))
);
}
```

</ngrx-code-example>

## Using `withFeature`

An alternative approach to custom features with input is using the `withFeature` utility, which offers more flexibility.
Expand Down