diff --git a/apps/demo/src/app/with-resource/with-resource.component.html b/apps/demo/src/app/with-resource/with-resource.component.html
new file mode 100644
index 00000000..cf301566
--- /dev/null
+++ b/apps/demo/src/app/with-resource/with-resource.component.html
@@ -0,0 +1,24 @@
+
withResource
+withResource doc page
+
+Single Resource
+value: {{ store.value() | json }}
+status: {{ store.status() }}
+error: {{ store.error() | json }}
+hasValue: {{ store.hasValue() }}
+@if (store.hasValue()) {
+ value: {{ store.value() | json }}
+}
+
+Named Resource
+{{ store.listValue() | json }}
+status: {{ store.listStatus() }}
+error: {{ store.listError() | json }}
+hasValue: {{ store.listHasValue() }}
+@if (store.listHasValue()) {
+ value: {{ store.listValue() | json }}
+}
diff --git a/apps/demo/src/app/with-resource/with-resource.component.ts b/apps/demo/src/app/with-resource/with-resource.component.ts
index 43b5d42b..d77d9ee8 100644
--- a/apps/demo/src/app/with-resource/with-resource.component.ts
+++ b/apps/demo/src/app/with-resource/with-resource.component.ts
@@ -9,7 +9,9 @@ const url = 'https://demo.angulararchitects.io/api/flight?from=Paris&to=';
export const FlightStore = signalStore(
withState({ flightTo: 'New York' }),
- withResource(({ flightTo }) => httpResource(() => `${url}${flightTo()}`)),
+ withResource(({ flightTo }) =>
+ httpResource(() => `${url}${flightTo()}`),
+ ),
withResource(({ flightTo }) => ({
list: httpResource(() => `${url}${flightTo()}`, {
defaultValue: [],
@@ -20,25 +22,7 @@ export const FlightStore = signalStore(
@Component({
selector: 'demo-with-resource',
imports: [JsonPipe],
- template: `,
- withResource
- withResource doc page
-
- Single Resource
- value: {{ store.value() | json }}
- status: {{ store.status() }}
- error: {{ store.error() | json }}
- hasValue: {{ store.hasValue() }}
-
- Named Resource
- {{ store.listValue() | json }}
- status: {{ store.listStatus() }}
- error: {{ store.listError() | json }}
- hasValue: {{ store.listHasValue() }} `,
+ templateUrl: './with-resource.component.html',
providers: [FlightStore],
})
export class WithResourceComponent {
diff --git a/docs/docs/with-entity-resources.md b/docs/docs/with-entity-resources.md
index a6eb3003..a4253d8d 100644
--- a/docs/docs/with-entity-resources.md
+++ b/docs/docs/with-entity-resources.md
@@ -6,8 +6,6 @@ title: withEntityResources()
import { withEntityResources } from '@angular-architects/ngrx-toolkit';
```
-> **⚠️ Important Note**: We have found some issues with `hasValue()` not narrowing correctly. If you have any insights or want to follow developments, please refer to our issue: ["bug(withResource and Mutations): hasValue() does not narrow the respective value signal #235"](https://github.com/angular-architects/ngrx-toolkit/issues/235)
-
`withEntityResources()` integrates Angular Resources that return arrays into NgRx SignalStore using the Entity helpers from `@ngrx/signals/entities`.
> Note: This feature builds on [withResource()](./with-resource.md) and adds an entity view over array resources.
diff --git a/docs/docs/with-resource.md b/docs/docs/with-resource.md
index 0bc1158d..f976e8e3 100644
--- a/docs/docs/with-resource.md
+++ b/docs/docs/with-resource.md
@@ -8,8 +8,6 @@ import { withResource } from '@angular-architects/ngrx-toolkit';
> **⚠️ Important Note**: This extension is very likely to land in NgRx once Angular's `Resource` enters developer preview. The `withResource` extension provides early access to this functionality and will be maintained for compatibility until the official NgRx implementation is available.
-> **⚠️ Important Note**: We have found some issues with `hasValue()` not narrowing correctly. If you have any insights or want to follow developments, please refer to our issue: ["bug(withResource and Mutations): hasValue() does not narrow the respective value signal #235"](https://github.com/angular-architects/ngrx-toolkit/issues/235)
-
`withResource()` is a feature in NgRx SignalStore that connects Angular's Resource API with the store.
The idea: you can use a store to directly manage async data (like loading from an API), and `withResource()` helps you wire that in.
diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts
index 232f392a..9d8bd12a 100644
--- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts
+++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts
@@ -14,7 +14,7 @@ import {
withState,
} from '@ngrx/signals';
import { of } from 'rxjs';
-import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types';
+import { Assert, IsEqual, Satisfies } from './test-utils/types';
import { ErrorHandling, mapToResource, withResource } from './with-resource';
import { Address, venice, vienna } from './with-resource/tests/util/fixtures';
import { paramsForResourceTypes } from './with-resource/tests/util/params-for-resource-types';
@@ -394,7 +394,7 @@ describe('withResource', () => {
}
});
- it('fails on hasValue as type predicate when not explicitly typed', () => {
+ it('removes undefined from the inferred value type after hasValue', () => {
const Store = signalStore(
{ providedIn: 'root' },
withResource(() => resource({ loader: () => Promise.resolve(1) })),
@@ -402,7 +402,7 @@ describe('withResource', () => {
const store = TestBed.inject(Store);
if (store.hasValue()) {
const _value = store.value();
- type _T1 = AssertNot>;
+ type _T1 = Assert>;
}
});
});
@@ -449,7 +449,7 @@ describe('withResource', () => {
}
});
- it('fails on hasValue as type predicate when not explicitly typed', () => {
+ it('removes undefined from the inferred value type after hasValue', () => {
const Store = signalStore(
{ providedIn: 'root' },
withResource(() => resource({ loader: () => Promise.resolve(1) }), {
@@ -459,11 +459,176 @@ describe('withResource', () => {
const store = TestBed.inject(Store);
if (store.hasValue()) {
const _value = store.value();
- type _T1 = AssertNot>;
+ type _T1 = Assert>;
+ }
+ });
+
+ it('removes undefined from the inferred value type after hasValue for named resources (no defaults)', () => {
+ const StoreNativeError = signalStore(
+ { providedIn: 'root' },
+ withResource(
+ () => {
+ return {
+ thing: resource({
+ loader: () => Promise.resolve(1),
+ }),
+ };
+ },
+ {
+ errorHandling: 'native',
+ },
+ ),
+ );
+ const store = TestBed.inject(StoreNativeError);
+
+ const _valueNative = store.thingValue();
+ type _TNative = Assert<
+ IsEqual
+ >;
+
+ if (store.thingHasValue()) {
+ const _value = store.thingValue();
+ type _T1 = Assert>;
+ }
+
+ const StorePreviousValue = signalStore(
+ { providedIn: 'root' },
+ withResource(
+ () => {
+ return {
+ thing: resource({
+ loader: () => Promise.resolve(1),
+ }),
+ };
+ },
+ {
+ errorHandling: 'previous value',
+ },
+ ),
+ );
+ const storePreviousValue = TestBed.inject(StorePreviousValue);
+
+ const _valuePrevious = storePreviousValue.thingValue();
+ type _TPrev = Assert<
+ IsEqual
+ >;
+
+ if (storePreviousValue.thingHasValue()) {
+ const _value = storePreviousValue.thingValue();
+ type _T1 = Assert>;
+ }
+
+ const StoreUndefinedValue = signalStore(
+ { providedIn: 'root' },
+ withResource(
+ () => {
+ return {
+ thing: resource({
+ loader: () => Promise.resolve(1),
+ }),
+ };
+ },
+ {
+ errorHandling: 'undefined value',
+ },
+ ),
+ );
+ const storeUndefinedValue = TestBed.inject(StoreUndefinedValue);
+
+ const _valueUndefined = storeUndefinedValue.thingValue();
+ type _TUndefined = Assert<
+ IsEqual
+ >;
+
+ if (storeUndefinedValue.thingHasValue()) {
+ const _value = storeUndefinedValue.thingValue();
+ type _T1 = Assert>;
}
});
});
+ it('removes undefined from the inferred value type after hasValue for named resources (defaults)', () => {
+ const StoreNativeError = signalStore(
+ { providedIn: 'root' },
+ withResource(
+ () => {
+ return {
+ thing: resource({
+ loader: () => Promise.resolve(1),
+ defaultValue: 0,
+ }),
+ };
+ },
+ {
+ errorHandling: 'native',
+ },
+ ),
+ );
+ const store = TestBed.inject(StoreNativeError);
+
+ const _valueNative = store.thingValue();
+ type _TNative = Assert>;
+
+ if (store.thingHasValue()) {
+ const _value = store.thingValue();
+ type _T1 = Assert>;
+ }
+
+ const StorePreviousValue = signalStore(
+ { providedIn: 'root' },
+ withResource(
+ () => {
+ return {
+ thing: resource({
+ loader: () => Promise.resolve(1),
+ defaultValue: 0,
+ }),
+ };
+ },
+ {
+ errorHandling: 'previous value',
+ },
+ ),
+ );
+ const storePreviousValue = TestBed.inject(StorePreviousValue);
+
+ const _valuePrevious = storePreviousValue.thingValue();
+ type _TPrev = Assert>;
+
+ if (storePreviousValue.thingHasValue()) {
+ const _value = storePreviousValue.thingValue();
+ type _T1 = Assert>;
+ }
+
+ const StoreUndefinedValue = signalStore(
+ { providedIn: 'root' },
+ withResource(
+ () => {
+ return {
+ thing: resource({
+ loader: () => Promise.resolve(1),
+ defaultValue: 0,
+ }),
+ };
+ },
+ {
+ errorHandling: 'undefined value',
+ },
+ ),
+ );
+ const storeUndefinedValue = TestBed.inject(StoreUndefinedValue);
+
+ const _valueUndefined = storeUndefinedValue.thingValue();
+ type _TUndefined = Assert<
+ IsEqual
+ >;
+
+ if (storeUndefinedValue.thingHasValue()) {
+ const _value = storeUndefinedValue.thingValue();
+ type _T1 = Assert>;
+ }
+ });
+
describe.each(['previous value', 'native'] as const)(
`Error Handling: %s`,
(errorHandling) => {
@@ -506,7 +671,7 @@ describe('withResource', () => {
}
});
- it('fails on hasValue as type predicate when not explicitly typed', () => {
+ it('removes undefined from the inferred value type after hasValue', () => {
const Store = signalStore(
{ providedIn: 'root' },
withResource(() => resource({ loader: () => Promise.resolve(1) }), {
@@ -516,7 +681,7 @@ describe('withResource', () => {
const store = TestBed.inject(Store);
if (store.hasValue()) {
const _value = store.value();
- type _T1 = AssertNot>;
+ type _T1 = Assert>;
}
});
},
diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts
index ef28ef7e..95cf6cec 100644
--- a/libs/ngrx-toolkit/src/lib/with-resource.ts
+++ b/libs/ngrx-toolkit/src/lib/with-resource.ts
@@ -20,6 +20,32 @@ import {
withProps,
} from '@ngrx/signals';
+/**
+ * `hasValue` captures the original type of `this` as `Original` and narrows
+ * only the resource portion of that type.
+ *
+ * The intersection order is significant because Signals are callable.
+ * TypeScript treats intersections of callable types as overloads and uses the
+ * first matching call signature. The narrowed resource must therefore come
+ * before `Original`; otherwise, `value()` resolves to the original
+ * `T | undefined`.
+ *
+ * ```ts
+ * type A = {
+ * id: () => number | undefined;
+ * hasId: (
+ * this: Original,
+ * ) => this is { id: () => number } & Original;
+ * };
+ *
+ * function read(a: A) {
+ * if (a.hasId()) {
+ * const id = a.id(); // number
+ * }
+ * }
+ * ```
+ */
+
export type ResourceResult = {
state: { value: T };
props: {
@@ -29,7 +55,9 @@ export type ResourceResult = {
snapshot: Signal>;
};
methods: {
- hasValue(): this is Resource>;
+ hasValue: >(
+ this: Original,
+ ) => this is Resource> & Original;
_reload(): boolean;
};
};
@@ -58,9 +86,26 @@ export type NamedResourceResult<
[Prop in keyof T as `${Prop & string}Snapshot`]: T[Prop]['snapshot'];
};
methods: {
- [Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource<
- Exclude
- >;
+ [Prop in keyof T as `${Prop & string}HasValue`]: <
+ Original extends Record<
+ `${Prop & string}Value`,
+ Signal<
+ T[Prop]['value'] extends Signal
+ ? HasUndefinedErrorHandling extends true
+ ? S | undefined
+ : S
+ : never
+ >
+ >,
+ >(
+ this: Original,
+ ) => this is Record<
+ `${Prop & string}Value`,
+ Signal<
+ Exclude ? S : never, undefined>
+ >
+ > &
+ Original;
} & {
[Prop in keyof T as `_${Prop & string}Reload`]: () => boolean;
};